Merge pull request #120 from nathonius/feat/102/table-pagination

Feat/102/table pagination
This commit is contained in:
Nathan 2024-06-30 14:41:55 -04:00 committed by GitHub
commit cb64714466
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2137 additions and 547 deletions

View file

@ -1,5 +1,4 @@
import type { App, Scope } from "obsidian";
import type { Modal } from "obsidian";
import type { App, Scope, Modal } from "obsidian";
export class ModalMock implements Modal {
constructor(public app: App) {}

View file

@ -5,6 +5,7 @@ import tseslint from "typescript-eslint";
import UnusedImportsPlugin from "eslint-plugin-unused-imports";
import PrettierConfig from "eslint-config-prettier";
import JestPlugin from "eslint-plugin-jest";
import * as ImportPlugin from "eslint-plugin-import";
export default tseslint.config(
{
@ -23,6 +24,7 @@ export default tseslint.config(
plugins: {
"unused-imports": UnusedImportsPlugin,
jest: JestPlugin,
import: ImportPlugin,
},
rules: {
"@typescript-eslint/restrict-template-expressions": "warn",
@ -47,6 +49,8 @@ export default tseslint.config(
argsIgnorePattern: "^_",
},
],
"import/order": "error",
"import/no-duplicates": "error",
},
},
PrettierConfig,

View file

@ -1,2 +1,2 @@
import { GithubLinkPlugin } from "src/plugin";
import { GithubLinkPlugin } from "./src/plugin";
export default GithubLinkPlugin;

1310
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,11 +20,13 @@
"@codemirror/view": "^6.23.0",
"@eslint/js": "^9.1.1",
"@jest/globals": "^29.7.0",
"@types/lodash": "^4.17.5",
"@types/node": "^20.11.19",
"builtin-modules": "3.3.0",
"esbuild": "0.20.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^28.2.0",
"eslint-plugin-unused-imports": "^3.1.0",
"jest": "^29.7.0",
@ -43,6 +45,7 @@
"@octokit/openapi-types": "^19.1.0",
"@octokit/plugin-rest-endpoint-methods": "^10.3.0",
"@octokit/request": "^8.2.0",
"lodash": "^4.17.21",
"queue": "^7.0.0"
}
}
}

View file

@ -1,3 +1,9 @@
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
import { Notice, requestUrl } from "obsidian";
import Queue from "queue";
import { PluginSettings, getCache, logger } from "../plugin";
import { RequestError, isSuccessResponse, promiseWithResolvers } from "../util";
import type {
CheckRunListResponse,
CodeResponse,
@ -6,16 +12,13 @@ import type {
IssueResponse,
IssueSearchParams,
IssueSearchResponse,
LinkMeta,
MaybePaginated,
PaginationMeta,
PullListParams,
PullListResponse,
PullResponse,
} from "./response";
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
import { RequestError, isSuccessResponse, promiseWithResolvers } from "src/util";
import { Notice, requestUrl } from "obsidian";
import { PluginSettings, getCache, logger } from "src/plugin";
import Queue from "queue";
import type { CacheEntry } from "./cache";
type RequestConfig = RequestUrlParam & { headers: Record<string, string> };
@ -25,17 +28,21 @@ export class GitHubApi {
private static rateLimitReset: Date | null = null;
private static q = new Queue({ autostart: true, concurrency: 1 });
public queueRequest(config: RequestUrlParam, token?: string): Promise<RequestUrlResponse> {
public queueRequest(
config: RequestUrlParam,
token?: string,
skipCache = false,
): Promise<MaybePaginated<RequestUrlResponse>> {
// Responses we (probably) have cached will skip the queue
if (getCache().get(config)) {
return this.githubRequest(config, token);
return this.githubRequest(config, token, skipCache);
}
const { resolve, reject, promise } = promiseWithResolvers<RequestUrlResponse>();
const { resolve, reject, promise } = promiseWithResolvers<MaybePaginated<RequestUrlResponse>>();
GitHubApi.q.push(() => {
return this.githubRequest(config, token)
.then((result) => {
resolve(result);
return this.githubRequest(config, token, skipCache)
.then((response) => {
resolve(response);
})
.catch((err) => {
reject(err);
@ -44,16 +51,65 @@ export class GitHubApi {
return promise;
}
public async getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
const result = await this.queueRequest({ url: `${GitHubApi.baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
return result.json as IssueResponse;
public getPaginationMeta(response: RequestUrlResponse): MaybePaginated<RequestUrlResponse> {
const meta = this.parseLinkHeader(response.headers["link"]);
return { meta, response };
}
public async listIssuesForToken(params: IssueListParams, token: string): Promise<IssueListResponse> {
public parseLinkHeader(link: string | undefined): PaginationMeta {
logger.debug(`Parsing link header: ${link}`);
const paginationMeta: PaginationMeta = {};
if (!link) {
return paginationMeta;
}
const linkRelPattern = /<(?<url>http[^\s?]+)\?(?<qp>[^\s>]*)>;\s*rel="(?<rel>[^\s"]*)"/g;
let match: RegExpExecArray | null = null;
do {
match = linkRelPattern.exec(link);
logger.debug(match);
if (match?.groups) {
const params = new URLSearchParams(match.groups.qp);
const page = parseInt(params.get("page") ?? "");
const per_page = parseInt(params.get("per_page") ?? "");
if (isNaN(page) || isNaN(per_page)) {
continue;
}
const linkMeta: LinkMeta = {
url: match.groups.url,
page,
per_page,
};
paginationMeta[match.groups.rel as keyof PaginationMeta] = linkMeta;
}
} while (match);
logger.debug(paginationMeta);
return paginationMeta;
}
public async getIssue(
org: string,
repo: string,
issue: number,
token?: string,
skipCache = false,
): Promise<IssueResponse> {
const { response } = await this.queueRequest(
{ url: `${GitHubApi.baseApi}/repos/${org}/${repo}/issues/${issue}` },
token,
skipCache,
);
return response.json as IssueResponse;
}
public async listIssuesForToken(
params: IssueListParams,
token: string,
skipCache = false,
): Promise<MaybePaginated<IssueListResponse>> {
const url = this.addParams(`${GitHubApi.baseApi}/issues`, params as Record<string, unknown>);
const result = await this.queueRequest({ url }, token);
return result.json as IssueListResponse;
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
return { meta, response: response.json as IssueListResponse };
}
public async listIssuesForRepo(
@ -61,20 +117,28 @@ export class GitHubApi {
repo: string,
params: IssueListParams = {},
token?: string,
): Promise<IssueListResponse> {
skipCache = false,
): Promise<MaybePaginated<IssueListResponse>> {
const url = this.addParams(`${GitHubApi.baseApi}/repos/${org}/${repo}/issues`, params as Record<string, unknown>);
const result = await this.queueRequest({ url }, token);
return result.json as IssueListResponse;
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
return { meta, response: response.json as IssueListResponse };
}
public async getPullRequest(org: string, repo: string, pr: number, token?: string): Promise<PullResponse> {
const result = await this.queueRequest(
public async getPullRequest(
org: string,
repo: string,
pr: number,
token?: string,
skipCache = false,
): Promise<PullResponse> {
const { response } = await this.queueRequest(
{
url: `${GitHubApi.baseApi}/repos/${org}/${repo}/pulls/${pr}`,
},
token,
skipCache,
);
return result.json as PullResponse;
return response.json as PullResponse;
}
public async listPullRequestsForRepo(
@ -82,26 +146,39 @@ export class GitHubApi {
repo: string,
params: PullListParams = {},
token?: string,
): Promise<PullListResponse> {
skipCache = false,
): Promise<MaybePaginated<PullListResponse>> {
const url = this.addParams(`${GitHubApi.baseApi}/repos/${org}/${repo}/pulls`, params as Record<string, unknown>);
const result = await this.queueRequest({ url }, token);
return result.json as PullListResponse;
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
return { meta, response: response.json as PullListResponse };
}
public async getCode(org: string, repo: string, path: string, branch: string, token?: string): Promise<CodeResponse> {
const result = await this.queueRequest(
public async getCode(
org: string,
repo: string,
path: string,
branch: string,
token?: string,
skipCache = false,
): Promise<CodeResponse> {
const { response } = await this.queueRequest(
{
url: `${GitHubApi.baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
},
token,
skipCache,
);
return result.json as CodeResponse;
return response.json as CodeResponse;
}
public async searchIssues(params: IssueSearchParams, token?: string): Promise<IssueSearchResponse> {
public async searchIssues(
params: IssueSearchParams,
token?: string,
skipCache = false,
): Promise<MaybePaginated<IssueSearchResponse>> {
const url = this.addParams(`${GitHubApi.baseApi}/search/issues`, params);
const result = await this.githubRequest({ url }, token);
return result.json as IssueSearchResponse;
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
return { meta, response: response.json as IssueSearchResponse };
}
public async listCheckRunsForRef(
@ -109,19 +186,21 @@ export class GitHubApi {
repo: string,
ref: string,
token?: string,
skipCache = false,
): Promise<CheckRunListResponse> {
const result = await this.githubRequest(
const { response } = await this.githubRequest(
{ url: `${GitHubApi.baseApi}/${org}/${repo}/commits/${ref}/check-runs` },
token,
skipCache,
);
return result.json as CheckRunListResponse;
return response.json as CheckRunListResponse;
}
private async githubRequest(
_config: RequestUrlParam,
token?: string,
skipCache = false,
): Promise<RequestUrlResponse> {
): Promise<MaybePaginated<RequestUrlResponse>> {
if (GitHubApi.rateLimitReset !== null && GitHubApi.rateLimitReset > new Date()) {
logger.warn(
`GitHub rate limit exceeded. No more requests will be made until ${GitHubApi.rateLimitReset.toLocaleTimeString()}`,
@ -133,17 +212,20 @@ export class GitHubApi {
}
const config = this.initHeaders(_config, token);
let cachedValue: CacheEntry | null = null;
// Check request cache first
const cachedValue = getCache().get(config);
if (this.cachedRequestIsRecent(cachedValue, skipCache)) {
logger.debug(`Request was too recent. Returning cached value for: ${cachedValue?.request.url}`);
logger.debug(cachedValue?.response);
return cachedValue!.response;
if (!skipCache) {
// Check request cache first
cachedValue = getCache().get(config);
if (this.cachedRequestIsRecent(cachedValue)) {
logger.debug(`Request was too recent. Returning cached value for: ${cachedValue?.request.url}`);
logger.debug(cachedValue?.response);
return this.getPaginationMeta(cachedValue!.response);
}
this.setCacheHeaders(config, cachedValue);
}
this.setCacheHeaders(config, cachedValue);
try {
logger.debug(`Request: ${config.url}`);
logger.debug(config);
@ -154,7 +236,7 @@ export class GitHubApi {
// Check for 304 response, return cached value
if (cachedValue?.response && response.status === 304) {
getCache().update(config);
return cachedValue.response;
return this.getPaginationMeta(cachedValue.response);
} else if (isSuccessResponse(response.status)) {
getCache().set(config, response);
}
@ -177,7 +259,7 @@ export class GitHubApi {
logger.warn("GitHub rate limit approaching.");
}
return response;
return this.getPaginationMeta(response);
} catch (err) {
logger.debug(err);
return Promise.reject(new RequestError(err as Error));
@ -223,8 +305,8 @@ export class GitHubApi {
/**
* Returns true if we can skip calling the API due to request age
*/
private cachedRequestIsRecent(cachedValue: CacheEntry | null, skipCache: boolean): boolean {
if (skipCache || !cachedValue) {
private cachedRequestIsRecent(cachedValue: CacheEntry | null): boolean {
if (!cachedValue) {
return false;
}
// Return the cached value if it was recent enough

View file

@ -1,16 +1,6 @@
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
import type {
IssueListParams,
IssueListResponse,
IssueResponse,
IssueSearchResponse,
PullListParams,
PullListResponse,
PullResponse,
RepoSearchResponse,
} from "./response";
import { logger } from "src/plugin";
import { isSuccessResponse } from "src/util";
import { logger } from "../plugin";
import { isSuccessResponse, sanitizeObject } from "../util";
interface CacheParams {
request: RequestUrlParam;
@ -84,7 +74,12 @@ export class RequestCache {
}
public get(request: RequestUrlParam): CacheEntry | null {
return this.entries[this.getCacheKey(request)] ?? null;
const entry: CacheEntry | null = this.entries[this.getCacheKey(request)] ?? null;
// Ensure headers are defined; some old cache entries might not have them
if (entry && !entry.response.headers) {
entry.response.headers = {};
}
return entry;
}
public set(request: RequestUrlParam, response: RequestUrlResponse): void {
@ -99,7 +94,11 @@ export class RequestCache {
// Slim down the data we store
const _request: Partial<RequestUrlParam> = { url: request.url, body: request.body };
const _response: Partial<RequestUrlResponse> = { json: response.json, status: response.status };
const _response: Partial<RequestUrlResponse> = {
json: response.json,
status: response.status,
headers: sanitizeObject(response.headers, { link: true }),
};
const entry = new CacheEntry(
_request as RequestUrlParam,
@ -153,155 +152,3 @@ export class RequestCache {
return request.url;
}
}
class OldCacheEntry<T> {
constructor(
public value: T,
public created: Date = new Date(),
public ttl: number = 20,
) {}
get expired(): boolean {
const expiry = this.created.getTime() + this.ttl * 60 * 1000;
return new Date().getTime() > expiry;
}
}
class OldQueryCache {
public readonly issueCache: Record<string, OldCacheEntry<IssueSearchResponse>> = {};
public readonly repoCache: Record<string, OldCacheEntry<RepoSearchResponse>> = {};
}
class OldRepoCache {
public readonly issueCache: Record<number, OldCacheEntry<IssueResponse>> = {};
public readonly issueListForRepoCache: Record<string, OldCacheEntry<IssueListResponse>> = {};
public readonly pullCache: Record<string, OldCacheEntry<PullResponse>> = {};
public readonly pullListForRepoCache: Record<string, OldCacheEntry<PullListResponse>> = {};
}
class OldOrgCache {
public readonly repos: Record<string, OldRepoCache> = {};
public readonly issueList: Record<string, OldCacheEntry<IssueListResponse>> = {};
}
/**
* @deprecated Should remove this in the one place its still used, but need an alternative solution first
*/
export class OldCache {
public readonly generic: Record<string, OldCacheEntry<unknown>> = {};
public readonly orgs: Record<string, OldOrgCache> = {};
public readonly queries = new OldQueryCache();
getGeneric(url: string): unknown {
return this.getCacheValue(this.generic[url] ?? null);
}
setGeneric(url: string, value: unknown): void {
this.generic[url] = new OldCacheEntry(value);
}
getIssue(org: string, repo: string, issue: number): IssueResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.issueCache[issue] ?? null);
}
setIssue(org: string, repo: string, issue: IssueResponse): void {
const issueCache = this.getRepoCache(org, repo).issueCache;
const existingCache = issueCache[issue.number];
if (existingCache) {
const now = new Date();
existingCache.created = now;
existingCache.value = issue;
} else {
issueCache[issue.number] = new OldCacheEntry<IssueResponse>(issue);
}
}
getIssueList(org: string, params: IssueListParams): IssueListResponse | null {
const orgCache = this.getOrgCache(org);
return this.getCacheValue(orgCache.issueList[JSON.stringify(params)] ?? null);
}
setIssueList(org: string, params: IssueListParams, value: IssueListResponse): void {
const orgCache = this.getOrgCache(org);
orgCache.issueList[JSON.stringify(params)] = new OldCacheEntry(value);
}
getIssueListForRepo(org: string, repo: string, params: IssueListParams): IssueListResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.issueListForRepoCache[JSON.stringify(params)] ?? null);
}
setIssueListForRepo(org: string, repo: string, params: IssueListParams, value: IssueListResponse): void {
const repoCache = this.getRepoCache(org, repo);
repoCache.issueListForRepoCache[JSON.stringify(params)] = new OldCacheEntry(value);
}
getPullRequest(org: string, repo: string, pullRequest: number): PullResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.pullCache[pullRequest] ?? null);
}
getPullListForRepo(org: string, repo: string, params: PullListParams): PullListResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.pullListForRepoCache[JSON.stringify(params)] ?? null);
}
setPullListForRepo(org: string, repo: string, params: PullListParams, value: PullListResponse): void {
const repoCache = this.getRepoCache(org, repo);
repoCache.pullListForRepoCache[JSON.stringify(params)] = new OldCacheEntry(value);
}
setPullRequest(org: string, repo: string, pullRequest: PullResponse): void {
const pullCache = this.getRepoCache(org, repo).pullCache;
const existingCache = pullCache[pullRequest.number];
if (existingCache) {
const now = new Date();
existingCache.created = now;
existingCache.value = pullRequest;
} else {
pullCache[pullRequest.number] = new OldCacheEntry<PullResponse>(pullRequest);
}
}
getIssueQuery(query: string): IssueSearchResponse | null {
return this.getCacheValue(this.queries.issueCache[query] ?? null);
}
setIssueQuery(query: string, result: IssueSearchResponse): void {
this.queries.issueCache[query] = new OldCacheEntry<IssueSearchResponse>(result);
}
getRepoQuery(query: string): RepoSearchResponse | null {
return this.getCacheValue(this.queries.repoCache[query] ?? null);
}
setRepoQuery(query: string, result: RepoSearchResponse): void {
this.queries.repoCache[query] = new OldCacheEntry<RepoSearchResponse>(result);
}
private getOrgCache(org: string): OldOrgCache {
let orgCache = this.orgs[org];
if (!orgCache) {
orgCache = this.orgs[org] = new OldOrgCache();
}
return orgCache;
}
private getRepoCache(org: string, repo: string) {
const orgCache = this.getOrgCache(org);
let repoCache = orgCache.repos[repo];
if (!repoCache) {
repoCache = orgCache.repos[repo] = new OldRepoCache();
}
return repoCache;
}
private getCacheValue<T>(cacheEntry: OldCacheEntry<T> | null): T | null {
if (!cacheEntry || cacheEntry.expired) {
return null;
} else {
return cacheEntry.value;
}
}
}

View file

@ -1,3 +1,10 @@
import { RequestError, mapObject } from "../util";
import type { GithubAccount } from "../settings";
import { PluginSettings } from "../plugin";
import { issueListSortFromQuery, pullListSortFromQuery, searchSortFromQuery } from "../query/sort";
import type { QueryParams } from "../query/types";
import { GitHubApi } from "./api";
import type {
CheckRunListResponse,
IssueListParams,
@ -6,20 +13,15 @@ import type {
IssueSearchParams,
IssueSearchResponse,
IssueTimelineResponse,
MaybePaginated,
PullListParams,
PullListResponse,
PullResponse,
TimelineCrossReferencedEvent,
} from "./response";
import type { RemoveIndexSignature } from "src/util";
import { RequestError, sanitizeObject } from "src/util";
import { GitHubApi } from "./api";
import { OldCache } from "./cache";
import type { GithubAccount } from "src/settings";
import { PluginSettings } from "src/plugin";
// TODO: Refactor this whole file into a class for better use in dataview queries, etc
const cache = new OldCache();
const tokenMatchRegex = /repo:(.+)\//;
const api = new GitHubApi();
@ -45,133 +47,151 @@ function getToken(org?: string, query?: string): string | undefined {
return account?.token;
}
export function getIssue(org: string, repo: string, issue: number): Promise<IssueResponse> {
return api.getIssue(org, repo, issue, getToken(org));
export function getIssue(org: string, repo: string, issue: number, skipCache = false): Promise<IssueResponse> {
return api.getIssue(org, repo, issue, getToken(org), skipCache);
}
export function getMyIssues(params: IssueListParams, org?: string, skipCache = false): Promise<IssueListResponse> {
export function getMyIssues(
params: QueryParams,
org?: string,
skipCache = false,
): Promise<MaybePaginated<IssueListResponse>> {
const account = getAccount(org);
if (!account?.token) {
return Promise.resolve([]);
return Promise.resolve({ meta: {}, response: [] });
}
const _params = sanitizeObject(params, {
assignee: false,
creator: false,
direction: true,
labels: true,
mentioned: false,
milestone: false,
page: true,
per_page: true,
since: true,
sort: true,
state: true,
filter: true,
org: false,
repo: false,
});
const listParams = mapObject<QueryParams, IssueListParams>(
params,
{
assignee: true,
creator: true,
direction: true,
labels: (params) => {
if (Array.isArray(params.labels)) {
return params.labels.join(",");
}
return params.labels;
},
mentioned: true,
page: true,
per_page: true,
since: true,
sort: (params) => issueListSortFromQuery(params),
state: true,
},
true,
true,
);
setPageSize(_params);
setPageSize(listParams);
if (Array.isArray(_params.labels)) {
_params.labels = _params.labels.join(",");
}
return api.listIssuesForToken(_params, account.token);
return api.listIssuesForToken(listParams, account.token, skipCache);
}
export function getIssuesForRepo(
params: IssueListParams,
params: QueryParams,
org: string,
repo: string,
skipCache = false,
): Promise<IssueListResponse> {
const _params = sanitizeObject(params, {
assignee: true,
creator: true,
direction: true,
labels: true,
mentioned: true,
milestone: true,
page: true,
per_page: true,
since: true,
sort: true,
state: true,
org: false,
repo: false,
filter: false,
});
): Promise<MaybePaginated<IssueListResponse>> {
const listParams = mapObject<QueryParams, IssueListParams>(
params,
{
assignee: true,
creator: true,
direction: true,
labels: (params) => {
if (Array.isArray(params.labels)) {
return params.labels.join(",");
}
return params.labels;
},
mentioned: true,
page: true,
per_page: true,
since: true,
sort: (params) => issueListSortFromQuery(params),
state: true,
},
true,
true,
);
setPageSize(_params);
setPageSize(listParams);
if (Array.isArray(_params.labels)) {
_params.labels = _params.labels.join(",");
}
return api.listIssuesForRepo(org, repo, _params, getToken(org));
return api.listIssuesForRepo(org, repo, listParams, getToken(org), skipCache);
}
export function getPullRequest(org: string, repo: string, pullRequest: number): Promise<PullResponse> {
return api.getPullRequest(org, repo, pullRequest, getToken(org));
export function getPullRequest(
org: string,
repo: string,
pullRequest: number,
skipCache = false,
): Promise<PullResponse> {
return api.getPullRequest(org, repo, pullRequest, getToken(org), skipCache);
}
export function getPullRequestsForRepo(params: PullListParams, org: string, repo: string): Promise<PullListResponse> {
const _params = sanitizeObject(params, {
org: false,
repo: false,
base: true,
direction: true,
head: true,
page: true,
per_page: true,
sort: true,
state: true,
});
export function getPullRequestsForRepo(
params: QueryParams,
org: string,
repo: string,
skipCache = false,
): Promise<MaybePaginated<PullListResponse>> {
const listParams = mapObject<QueryParams, PullListParams>(
params,
{
direction: true,
page: true,
per_page: true,
sort: (params) => pullListSortFromQuery(params),
state: true,
},
true,
true,
);
setPageSize(_params);
return api.listPullRequestsForRepo(org, repo, _params, getToken(org));
setPageSize(listParams);
return api.listPullRequestsForRepo(org, repo, listParams, getToken(org), skipCache);
}
export function listCheckRunsForRef(org: string, repo: string, ref: string): Promise<CheckRunListResponse> {
return api.listCheckRunsForRef(org, repo, ref, getToken(org));
export function listCheckRunsForRef(
org: string,
repo: string,
ref: string,
skipCache = false,
): Promise<CheckRunListResponse> {
return api.listCheckRunsForRef(org, repo, ref, getToken(org), skipCache);
}
export async function searchIssues(
params: RemoveIndexSignature<IssueSearchParams>,
params: QueryParams,
query: string,
org?: string,
skipCache = false,
): Promise<IssueSearchResponse> {
const _params = sanitizeObject(params, {
q: false,
baseUrl: false,
headers: false,
mediaType: false,
order: true,
page: true,
per_page: true,
request: false,
sort: true,
});
): Promise<MaybePaginated<IssueSearchResponse>> {
const searchParams = mapObject<QueryParams, IssueSearchParams>(
params,
{
q: () => query,
sort: (params) => searchSortFromQuery(params),
order: (params) => params.order,
page: (params) => params.page,
per_page: (params) => params.per_page,
},
true,
true,
);
setPageSize(_params);
_params.q = query;
const cachedResponse = cache.getIssueQuery(query);
if (cachedResponse && !skipCache) {
return Promise.resolve(cachedResponse);
}
const response = await api.searchIssues(_params, getToken(org, query));
cache.setIssueQuery(query, response);
return response;
setPageSize(searchParams);
return api.searchIssues(searchParams, getToken(org, query), skipCache);
}
// TODO: This is in the wrong place and should be at the API level to be properly cached
export async function getPRForIssue(timelineUrl: string, org?: string): Promise<string | null> {
let response: IssueTimelineResponse | null = null;
let result: IssueTimelineResponse | null = null;
try {
response = (await api.queueRequest({ url: timelineUrl }, getToken(org))).json;
const { response } = await api.queueRequest({ url: timelineUrl }, getToken(org));
result = response.json as IssueTimelineResponse;
} catch (err) {
// 404 means there's no timeline for this, we can ignore the error
if (err instanceof RequestError && err.status === 404) {
@ -180,12 +200,12 @@ export async function getPRForIssue(timelineUrl: string, org?: string): Promise<
throw err;
}
}
if (!response) {
if (!result) {
return null;
}
// TODO: Figure out a better/more reliable way to do this.
const crossRefEvent = response.find((_evt) => {
const crossRefEvent = result.find((_evt) => {
const evt = _evt as Partial<TimelineCrossReferencedEvent>;
return evt.event === "cross-referenced" && evt.source?.issue?.pull_request?.html_url;
}) as TimelineCrossReferencedEvent | undefined;

View file

@ -13,6 +13,24 @@ export interface PaginationParams {
page?: number;
}
export interface LinkMeta {
url: string;
page: number;
per_page: number;
}
export interface PaginationMeta {
first?: LinkMeta;
prev?: LinkMeta;
next?: LinkMeta;
last?: LinkMeta;
}
export interface MaybePaginated<T> {
meta: PaginationMeta;
response: T;
}
// Response Types
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
export type IssueListResponse = RestEndpointMethodTypes["issues"]["list"]["response"]["data"];
@ -52,7 +70,9 @@ export type PullListParams = PaginationParams & {
};
export type IssueSearchParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
export function getSearchResultIssueStatus(issue: IssueSearchResponse["items"][number]): IssueStatus {
export function getSearchResultIssueStatus(
issue: Pick<IssueSearchResponse["items"][number], "state" | "state_reason" | "pull_request" | "closed_at">,
): IssueStatus {
if (issue.pull_request?.merged_at || issue.state_reason === "completed") {
return IssueStatus.Done;
} else if (issue.closed_at || issue.state === "closed") {

View file

@ -1,13 +1,13 @@
import { IssueStatus, getIssueStatus, getPRStatus } from "src/github/response";
import { getIssue, getPullRequest } from "../github/github";
import { setIssueIcon, setPRIcon, setPRMergeableIcon } from "src/icon";
import type { ParsedUrl } from "../github/url-parse";
import { PluginSettings } from "src/plugin";
import type { PullResponse } from "src/github/response";
import { RequestError } from "src/util";
import { parseUrl } from "../github/url-parse";
import { setIcon } from "obsidian";
import { IssueStatus, getIssueStatus, getPRStatus } from "../github/response";
import { setIssueIcon, setPRIcon, setPRMergeableIcon } from "../icon";
import { PluginSettings } from "../plugin";
import type { PullResponse } from "../github/response";
import { RequestError } from "../util";
import { parseUrl } from "../github/url-parse";
import type { ParsedUrl } from "../github/url-parse";
import { getIssue, getPullRequest } from "../github/github";
interface TagConfig {
icon: HTMLSpanElement;

View file

@ -1,10 +1,9 @@
import { expect, jest, test } from "@jest/globals";
import { GithubLinkPlugin, PluginData, PluginSettings, getCache } from "./plugin";
import { beforeEach, describe } from "node:test";
import { expect, jest, test, describe, beforeEach } from "@jest/globals";
import type { Plugin, RequestUrlResponse } from "obsidian";
import { App } from "obsidian";
import type { PluginMock } from "../__mocks__/obsidian/Plugin";
import * as manifest from "../manifest.json";
import type { PluginMock } from "__mocks__/obsidian/Plugin";
import { GithubLinkPlugin, PluginData, PluginSettings, getCache } from "./plugin";
import type { GithubLinkPluginSettings } from "./settings";
import { DEFAULT_SETTINGS } from "./settings";
import { CacheEntry, RequestCache } from "./github/cache";
@ -57,7 +56,7 @@ describe("GithubLinkPlugin", () => {
test("should load stored cache", async () => {
const cacheEntry = new CacheEntry(
{ url: "mock" },
{ json: "mock" } as RequestUrlResponse,
{ json: "mock", headers: {} } as RequestUrlResponse,
new Date(),
null,
null,
@ -76,6 +75,8 @@ describe("GithubLinkPlugin", () => {
{ stored: { tagTooltips: false }, name: "tagTooltips" },
{ stored: { minRequestSeconds: 69 }, name: "minRequestSeconds" },
{ stored: { logLevel: LogLevel.Debug }, name: "logLevel" },
{ stored: { showPagination: !DEFAULT_SETTINGS.showPagination }, name: "showPagination" },
{ stored: { showRefresh: !DEFAULT_SETTINGS.showRefresh }, name: "showRefresh" },
])("should merge stored and default settings ($name)", async ({ stored }) => {
plugin = new GithubLinkPlugin(app, manifest);
mockedPlugin(plugin).data = { settings: stored };

View file

@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, GithubLinkPluginSettingsTab } from "./settings";
import { Logger } from "./logger";
import type { GithubLinkPluginData, GithubLinkPluginSettings } from "./settings";
import { InlineRenderer } from "./inline/inline";
import { Plugin } from "obsidian";
import { QueryProcessor } from "./query/processor";
import { createInlineViewPlugin } from "./inline/view-plugin";
import { RequestCache } from "./github/cache";
import { QueryProcessor } from "./query/processor";
export const PluginSettings: GithubLinkPluginSettings = { ...DEFAULT_SETTINGS };
export const PluginData: GithubLinkPluginData = { cache: null, settings: PluginSettings };
@ -33,6 +35,8 @@ export class GithubLinkPlugin extends Plugin {
tagShowPRMergeable: data.tagShowPRMergeable ?? PluginSettings.tagShowPRMergeable,
tagTooltips: data.tagTooltips ?? PluginSettings.tagTooltips,
defaultAccount: data.defaultAccount ?? PluginSettings.defaultAccount,
showPagination: data.showPagination ?? PluginSettings.showPagination,
showRefresh: data.showRefresh ?? PluginSettings.showRefresh,
};
const newData: GithubLinkPluginData = {
cache: data.cache ?? PluginData.cache,

View file

@ -1,13 +1,16 @@
import { parseUrl, repoAPIToBrowserUrl } from "src/github/url-parse";
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { parseUrl, repoAPIToBrowserUrl } from "../../github/url-parse";
import { DateFormat } from "src/util";
import type { IssueSearchResponse } from "src/github/response";
import { DateFormat } from "../../util";
import type { IssueListResponse } from "../../github/response";
import type { TableResult } from "../types";
export interface ColumnGetter<T> {
header: string;
cell: (row: T, el: HTMLTableCellElement) => void | Promise<void>;
}
export type ColumnsMap<T> = Record<string, ColumnGetter<T>>;
export type ColumnsMap = Record<string, ColumnGetter<TableResult[number]>>;
export function DateCell(value: string | undefined | null, el: HTMLTableCellElement) {
el.classList.add("github-link-table-date");
@ -28,7 +31,7 @@ export function DateCell(value: string | undefined | null, el: HTMLTableCellElem
/**
* Issue and PR columns share types, so some columns are shared
*/
export const CommonIssuePRColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
export const CommonIssuePRColumns: ColumnsMap = {
number: {
header: "Number",
cell: (row, el) => {
@ -40,7 +43,7 @@ export const CommonIssuePRColumns: ColumnsMap<IssueSearchResponse["items"][numbe
header: "Repo",
cell: (row, el) => {
el.classList.add("github-link-table-repo");
const url = repoAPIToBrowserUrl(row.repository_url);
const url = repoAPIToBrowserUrl((row as IssueListResponse[number]).repository_url);
const parsed = parseUrl(url);
el.createEl("a", { text: parsed.repo, href: url, attr: { target: "_blank" } });
},

View file

@ -1,7 +1,21 @@
import { QueryType } from "../params";
import type { QueryType } from "../types";
import { IssueColumns } from "./issue";
import { PullRequestColumns } from "./pull-request";
import { RepoColumns } from "./repo";
/*
We have to do this workaround with the QueryType enum because ts-jest does not support
using constant enums like this; see https://github.com/kulshekhar/ts-jest/pull/308/files
*/
export const DEFAULT_COLUMNS = {
[QueryType.Issue]: ["number", "title", "author", "created", "status"],
[QueryType.PullRequest]: ["number", "title", "author", "created", "status"],
[QueryType.Repo]: [],
["issue" as QueryType]: ["number", "title", "author", "created", "status"],
["pull-request" as QueryType]: ["number", "title", "author", "created", "status"],
["repo" as QueryType]: [],
};
export const ALL_COLUMNS = {
["issue" as QueryType]: IssueColumns,
["pull-request" as QueryType]: PullRequestColumns,
["repo" as QueryType]: RepoColumns,
};

View file

@ -1,11 +1,12 @@
import { getSearchResultIssueStatus, type IssueSearchResponse } from "src/github/response";
import type { IssueListResponse, IssueSearchResponse } from "../../github/response";
import { getSearchResultIssueStatus } from "../../github/response";
import { setIssueIcon } from "../../icon";
import { titleCase } from "../../util";
import { createTag } from "../../inline/inline";
import { getPRForIssue } from "../../github/github";
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
import { setIssueIcon } from "src/icon";
import { titleCase } from "src/util";
import { createTag } from "src/inline/inline";
import { getPRForIssue } from "src/github/github";
export const IssueColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
export const IssueColumns: ColumnsMap = {
...CommonIssuePRColumns,
status: {
header: "Status",
@ -14,12 +15,18 @@ export const IssueColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
const status = getSearchResultIssueStatus(row);
const icon = wrapper.createSpan({ cls: "github-link-status-icon" });
setIssueIcon(icon, status);
wrapper.createSpan({ text: row.state_reason === "not_planned" ? "Not Planned" : titleCase(status) });
wrapper.createSpan({
text:
(row as IssueSearchResponse["items"][number]).state_reason === "not_planned"
? "Not Planned"
: titleCase(status),
});
},
},
pr: {
header: "PR",
cell: async (row, el) => {
cell: async (_row, el) => {
const row = _row as IssueListResponse[number];
// TODO: Figure out how to include org here for private repos
if (!row.timeline_url) {
return;

View file

@ -1,9 +1,9 @@
import { getSearchResultIssueStatus, IssueStatus, type IssueSearchResponse } from "src/github/response";
import { getSearchResultIssueStatus, IssueStatus } from "../../github/response";
import { setPRIcon } from "../../icon";
import { titleCase } from "../../util";
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
import { setPRIcon } from "src/icon";
import { titleCase } from "src/util";
export const PullRequestColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
export const PullRequestColumns: ColumnsMap = {
...CommonIssuePRColumns,
status: {
header: "Status",

View file

@ -1,4 +1,3 @@
import type { RepoSearchResponse } from "src/github/response";
import type { ColumnsMap } from "./base";
export const RepoColumns: ColumnsMap<RepoSearchResponse["items"][number]> = {};
export const RepoColumns: ColumnsMap = {};

View file

@ -1,82 +0,0 @@
import { getProp, titleCase } from "src/util";
import type { BaseParams } from "./params";
import { DEFAULT_COLUMNS } from "./column/defaults";
import { IssueColumns } from "./column/issue";
import { PullRequestColumns } from "./column/pull-request";
import { QueryType } from "./params";
import { RepoColumns } from "./column/repo";
import { setIcon } from "obsidian";
const ALL_COLUMNS = {
[QueryType.PullRequest]: PullRequestColumns,
[QueryType.Issue]: IssueColumns,
[QueryType.Repo]: RepoColumns,
};
export function renderTable<T extends { items: unknown[] } | unknown[]>(
params: BaseParams,
result: T,
el: HTMLElement,
renderFn: (element: HTMLElement, skipCache?: boolean) => Promise<void>,
externalLink?: string,
) {
el.empty();
const tableWrapper = el.createDiv({ cls: "github-link-table-wrapper" });
const tableScrollWrapper = tableWrapper.createDiv({ cls: "github-link-table-scroll-wrapper" });
const table = tableScrollWrapper.createEl("table", { cls: "github-link-table" });
if (params.refresh) {
const refresh = tableWrapper.createDiv({ cls: "github-link-table-refresh" });
if (externalLink) {
refresh.createEl("a", {
cls: "github-link-table-refresh-external-link",
text: "View on GitHub",
href: externalLink,
attr: { target: "_blank" },
});
}
const refreshButton = refresh.createEl("button", {
cls: "clickable-icon",
attr: { "aria-label": "Refresh Results" },
});
refreshButton.addEventListener("click", () => {
void renderFn(el, true);
});
setIcon(refreshButton, "refresh-cw");
}
const thead = table.createEl("thead");
let columns = params.columns;
if (!columns || columns.length === 0) {
columns = DEFAULT_COLUMNS[params.queryType];
}
// Ensure columns are lowercase
columns = columns.map((c) => c.toLowerCase());
for (const col of columns) {
const th = thead.createEl("th");
// Get predefined header if available
th.setText(ALL_COLUMNS[params.queryType][col]?.header ?? titleCase(col));
}
const tbody = table.createEl("tbody");
const items = Array.isArray(result) ? result : result.items;
for (const row of items) {
const tr = tbody.createEl("tr");
for (const col of columns) {
const cell = tr.createEl("td");
const renderer = ALL_COLUMNS[params.queryType][col];
if (renderer) {
void renderer.cell(row, cell);
} else {
const cellVal = getProp(row, col);
if (cellVal !== null) {
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
} else {
cell.setText("");
}
}
}
}
}

View file

@ -1,47 +0,0 @@
import type { IssueListParams, PullListParams } from "src/github/response";
import { parseYaml } from "obsidian";
export enum OutputType {
Table = "table",
}
export enum QueryType {
PullRequest = "pull-request",
Issue = "issue",
Repo = "repo",
}
export interface BaseParams {
outputType: OutputType;
queryType: QueryType;
columns: string[];
refresh?: boolean;
}
export type TableQueryParams<T> = BaseParams & { query: string; org?: string } & T & { q: never };
export type TableParams<T extends IssueListParams | PullListParams> = T & BaseParams;
export function processParams(source: string): BaseParams | null {
let params: BaseParams;
try {
params = parseYaml(source);
} catch (e) {
console.error(`Github Link: YAML Parsing failed, attempting simplistic parsing\n${e}`);
params = Object.fromEntries(source.split("\n").map((l) => l.split(/:\s?/)));
}
params.refresh = params.refresh ?? true;
return params ?? null;
}
export function isTableQueryParams<T>(params: BaseParams): params is TableQueryParams<T> {
return params.outputType === OutputType.Table && Boolean((params as TableQueryParams<T>).query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isTableParams(params: BaseParams): params is TableParams<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return params.outputType === OutputType.Table && !(params as TableQueryParams<any>).query;
}

View file

@ -1,50 +1,11 @@
import { type MarkdownPostProcessorContext } from "obsidian";
import type { TableParams, TableQueryParams } from "./params";
import { QueryType, isTableParams, isTableQueryParams, processParams } from "./params";
import { renderTable } from "./output";
import { searchIssues, getIssuesForRepo, getMyIssues, getPullRequestsForRepo } from "src/github/github";
import type { IssueListParams, IssueSearchParams, PullListParams } from "src/github/response";
import type { MarkdownPostProcessorContext } from "obsidian";
import { GithubQuery } from "./query";
export async function QueryProcessor(
source: string,
el: HTMLElement,
_ctx: MarkdownPostProcessorContext,
): Promise<void> {
const params = processParams(source);
if (!params) {
// TODO: show an error instead
el.setText(source);
return;
}
const renderFn = async (element: HTMLElement, skipCache = false) => {
let response: { items: unknown[] } | unknown[] | undefined = undefined;
let externalLink: string | undefined;
if (isTableQueryParams(params)) {
if (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest) {
const queryParams = params as TableQueryParams<IssueSearchParams>;
response = await searchIssues(params, params.query, queryParams.org, skipCache);
externalLink = `https://github.com/search?q=${encodeURIComponent(params.query)}`;
}
} else if (isTableParams(params)) {
if (params.queryType === QueryType.Issue) {
const issueParams = params as TableParams<IssueListParams>;
if (issueParams.org && issueParams.repo) {
response = await getIssuesForRepo(issueParams, issueParams.org, issueParams.repo, skipCache);
} else {
response = await getMyIssues(issueParams, issueParams.org, skipCache);
}
} else if (params.queryType === QueryType.PullRequest) {
const pullParams = params as TableParams<PullListParams>;
if (pullParams.org && pullParams.repo) {
response = await getPullRequestsForRepo(pullParams, pullParams.org, pullParams.repo);
}
}
}
if (response) {
renderTable(params, response, element, renderFn, externalLink);
}
};
await renderFn(el);
const query = new GithubQuery(el);
await query.init(source);
}

225
src/query/query.ts Normal file
View file

@ -0,0 +1,225 @@
import { parseYaml, setIcon } from "obsidian";
import { searchIssues, getIssuesForRepo, getMyIssues, getPullRequestsForRepo } from "../github/github";
import type { MaybePaginated, PaginationMeta } from "../github/response";
import { PluginSettings } from "../plugin";
import { getProp, isEqual, titleCase } from "../util";
import { ALL_COLUMNS, DEFAULT_COLUMNS } from "./column/defaults";
import type { QueryParams, TableResult } from "./types";
import { OutputType, QueryType } from "./types";
export class GithubQuery {
private params!: QueryParams;
private result: TableResult | null = null;
private resultMeta: PaginationMeta | null = null;
constructor(private readonly hostElement: HTMLElement) {}
public async init(source: string): Promise<void> {
const parsedParams = this.parseCodeblock(source);
if (!parsedParams) {
console.error(`Github Link: simplistic parsing failed`);
} else {
await this.setParams(parsedParams);
}
}
/**
* Setting the parameters triggers calling the API
*/
public async setParams(newParams: QueryParams = this.params, forceUpdate = false): Promise<void> {
const currentParams = this.params;
this.params = newParams;
if (forceUpdate || !isEqual(currentParams, newParams)) {
const result = await this.executeQuery(forceUpdate);
if (result) {
this.setResult(result.response, result.meta);
}
}
}
public setResult(result: TableResult, meta: PaginationMeta): void {
this.result = result;
this.resultMeta = meta;
this.render();
}
public parseCodeblock(source: string): QueryParams | null {
let params: QueryParams | null;
try {
params = parseYaml(source) as QueryParams;
} catch (e) {
console.error(`Github Link: YAML Parsing failed, attempting simplistic parsing`);
console.error(e);
params = Object.fromEntries(source.split("\n").map((l) => l.split(/:\s?/))) as QueryParams;
}
return params ?? null;
}
public async executeQuery(skipCache = false): Promise<MaybePaginated<TableResult> | null> {
const params = this.params;
if (params.outputType === OutputType.Table) {
// Custom Query
if (params.query && (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest)) {
const { meta, response } = await searchIssues(params, params.query, params.org, skipCache);
return { meta, response: response.items };
}
// Issue query with org and repo provided
else if (params.queryType === QueryType.Issue && params.org && params.repo) {
return await getIssuesForRepo(params, params.org, params.repo, skipCache);
}
// Issue query without org or repo provided
else if (params.queryType === QueryType.Issue) {
return await getMyIssues(params, params.org, skipCache);
}
// Pull request query with org and repo provided
else if (params.queryType === QueryType.PullRequest && params.org && params.repo) {
return await getPullRequestsForRepo(params, params.org, params.repo, skipCache);
}
}
return null;
}
public render(): void {
if (!this.result) {
throw new Error("Attempted to render table before there was a result.");
}
this.hostElement.empty();
const tableWrapper = this.hostElement.createDiv({ cls: "github-link-table-wrapper" });
const tableScrollWrapper = tableWrapper.createDiv({ cls: "github-link-table-scroll-wrapper" });
const table = tableScrollWrapper.createEl("table", { cls: "github-link-table" });
const queryType = this.params.queryType;
// Use default columns if none are provided
let columns = this.params.columns;
if (!columns || columns.length === 0) {
columns = DEFAULT_COLUMNS[queryType];
}
// Ensure columns are lowercase
columns = columns.map((c) => c.toLowerCase());
// Render
this.renderFooter(this.params, this.result, this.resultMeta, tableWrapper);
this.renderHeader(table, queryType, columns);
this.renderBody(table, queryType, columns, this.result);
}
private renderHeader(table: HTMLTableElement, queryType: QueryType, columns: string[]): void {
const thead = table.createEl("thead");
for (const col of columns) {
const th = thead.createEl("th");
// Get predefined header if available, otherwise try and create a title
th.setText(ALL_COLUMNS[queryType][col]?.header ?? titleCase(col));
}
}
private renderBody(table: HTMLTableElement, queryType: QueryType, columns: string[], result: TableResult): void {
const tbody = table.createEl("tbody");
for (const row of result) {
const tr = tbody.createEl("tr");
for (const col of columns) {
this.renderCell(tr, queryType, col, row);
}
}
}
private renderCell(tr: HTMLTableRowElement, queryType: QueryType, column: string, row: TableResult[number]): void {
const cell = tr.createEl("td");
const renderer = ALL_COLUMNS[queryType][column];
if (renderer) {
void renderer.cell(row, cell);
} else {
const cellVal = getProp(row, column);
if (cellVal !== null) {
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
} else {
cell.setText("");
}
}
}
private renderFooter(
params: QueryParams,
result: TableResult,
meta: PaginationMeta | null,
parent: HTMLElement,
): void {
const footer = parent.createDiv({ cls: "github-link-table-footer" });
// Add external link to footer if available
const externalLink = this.getExternalLink(params);
if (externalLink) {
footer.createEl("a", {
cls: "github-link-table-footer-external-link",
text: "View on GitHub",
href: externalLink,
attr: { target: "_blank" },
});
}
this.renderPagination(meta, footer);
if (PluginSettings.showRefresh) {
const refreshButton = footer.createEl("button", {
cls: "clickable-icon",
attr: { "aria-label": "Refresh Results" },
});
refreshButton.addEventListener("click", () => {
void this.setParams(this.params, true);
});
setIcon(refreshButton, "refresh-cw");
}
}
private renderPagination(meta: PaginationMeta | null, parent: HTMLElement): void {
if (PluginSettings.showPagination && this.hasSomeRel(meta)) {
const pagination = parent.createDiv({ cls: "github-link-table-pagination" });
// First, previous
if (meta?.first && (!meta.prev || meta.prev.page !== meta.first.page)) {
const first = pagination.createEl("a", { text: "<<", href: "#", attr: { role: "button" } });
first.addEventListener("click", () => {
void this.setParams({ ...this.params, page: meta.first?.page });
});
}
if (meta?.prev) {
const prev = pagination.createEl("a", { text: meta.prev.page.toString(), href: "#", attr: { role: "button" } });
prev.addEventListener("click", () => {
void this.setParams({ ...this.params, page: meta.prev?.page });
});
}
// Current Page
pagination.createSpan({ text: (this.params.page ?? 1).toString() });
// Next, last
if (meta?.next) {
const next = pagination.createEl("a", { text: meta.next.page.toString(), href: "#", attr: { role: "button" } });
next.addEventListener("click", () => {
void this.setParams({ ...this.params, page: meta.next?.page });
});
}
if (meta?.last && (!meta.next || meta.next.page !== meta.last.page)) {
const last = pagination.createEl("a", { text: ">>", href: "#", attr: { role: "button" } });
last.addEventListener("click", () => {
void this.setParams({ ...this.params, page: meta.last?.page });
});
}
}
}
private hasSomeRel(meta: PaginationMeta | null): boolean {
return Boolean(meta && (meta.first || meta.prev || meta.next || meta.last));
}
private getExternalLink(params: QueryParams): string | null {
// Custom search query
if (params.query && (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest)) {
return `https://github.com/search?q=${encodeURIComponent(params.query)}`;
} else {
return null;
}
}
}

32
src/query/sort.ts Normal file
View file

@ -0,0 +1,32 @@
import type { IssueSearchParams, IssueListParams, PullListParams } from "../github/response";
import type { QueryParams } from "./types";
/**
* Utility function to transform generic param sort into sort for issue search
*/
export function searchSortFromQuery(params: QueryParams): IssueSearchParams["sort"] {
if (params.sort !== "popularity" && params.sort !== "long-running") {
return params.sort;
}
return undefined;
}
/**
* Utility function to transform generic param sort into sort for issue list
*/
export function issueListSortFromQuery(params: QueryParams): IssueListParams["sort"] {
if (params.sort && ["created", "updated", "comments"].includes(params.sort)) {
return params.sort as IssueListParams["sort"];
}
return undefined;
}
/**
* Utility function to transform generic param sort into sort for pull list
*/
export function pullListSortFromQuery(params: QueryParams): PullListParams["sort"] {
if (params.sort && ["created", "updated", "popularity", "long-running"].includes(params.sort)) {
return params.sort as PullListParams["sort"];
}
return undefined;
}

102
src/query/types.ts Normal file
View file

@ -0,0 +1,102 @@
import type { IssueListResponse, IssueSearchResponse, PullListResponse } from "../github/response";
export type TableResult = IssueSearchResponse["items"] | IssueListResponse | PullListResponse;
export enum OutputType {
Table = "table",
}
export enum QueryType {
PullRequest = "pull-request",
Issue = "issue",
Repo = "repo",
}
export interface BaseParams {
refresh?: boolean;
}
/**
* Not all fields are supported by all query types
*/
export interface QueryParams {
outputType: OutputType;
queryType: QueryType;
columns: string[];
/**
* Custom query. This will override most other options.
*/
query?: string;
/**
* Pagination page size
*/
per_page?: number;
/**
* Pagination page number
*/
page?: number;
/**
* Repository name
*/
repo?: string;
/**
* Organization or user name
*/
org?: string;
milestone?: string;
state?: "open" | "closed" | "all";
assignee?: "none" | "*" | string;
creator?: string;
mentioned?: string;
labels?: string | string[];
/**
* "comments" - Issues only
* "popularity", "long-running" - Pull requests only
* "reactions" and "interactions" - Search only
*/
sort?:
| "created"
| "updated"
| "comments"
| "popularity"
| "long-running"
| "reactions"
| "reactions-+1"
| "reactions--1"
| "reactions-smile"
| "reactions-thinking_face"
| "reactions-heart"
| "reactions-tada"
| "interactions";
/**
* Sort direction, for most queries
*/
direction?: "asc" | "desc";
/**
* Sort direction, for search queries
*/
order?: "asc" | "desc";
since?: string;
/**
* Issue filter type
*/
filter?: "assigned" | "created" | "mentioned" | "subscribed" | "repos" | "all";
/**
* Pull request branch
*/
head?: string;
/**
* Pull request target
*/
base?: string;
}

View file

@ -1,9 +1,9 @@
import type { App } from "obsidian";
import { Setting } from "obsidian";
import type { GithubAccount } from "./types";
import { AuthModal } from "src/auth-modal";
import { auth } from "src/github/auth";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { AuthModal } from "../auth-modal";
import { auth } from "../github/auth";
import type { GithubAccount } from "./types";
export class AccountSettings {
authModal: AuthModal | null = null;

View file

@ -95,6 +95,28 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName("Show refresh button")
.setDesc("Add a refresh button to tables to manually skip the cache.")
.addToggle((toggle) => {
toggle.setValue(PluginSettings.showRefresh);
toggle.onChange((value) => {
PluginSettings.showRefresh = value;
void this.saveSettings();
});
});
new Setting(containerEl)
.setName("Show pagination")
.setDesc("For query results with more than a single page of results, show pagination controls below the table.")
.addToggle((toggle) => {
toggle.setValue(PluginSettings.showPagination);
toggle.onChange((value) => {
PluginSettings.showPagination = value;
void this.saveSettings();
});
});
new Setting(containerEl)
.setName("Status tooltips")
.setDesc("Add a tooltip to issue and pull request status icons with status text")

View file

@ -18,6 +18,8 @@ export interface GithubLinkPluginSettings {
accounts: GithubAccount[];
defaultAccount?: string;
defaultPageSize: number;
showPagination: boolean;
showRefresh: boolean;
logLevel: LogLevel;
tagTooltips: boolean;
tagShowPRMergeable: boolean;
@ -29,6 +31,8 @@ export interface GithubLinkPluginSettings {
export const DEFAULT_SETTINGS: GithubLinkPluginSettings = {
accounts: [],
defaultPageSize: 10,
showPagination: true,
showRefresh: true,
logLevel: LogLevel.Error,
tagTooltips: false,
tagShowPRMergeable: false,

View file

@ -1,7 +1,16 @@
import { isEqual as lodashIsEqual } from "lodash";
export type RemoveIndexSignature<T> = {
[K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K];
};
/**
* Small wrapper around lodash isEqual to tell typescript to stop complaining about its any types
*/
export function isEqual<T, R>(value: T, other: R): boolean {
return lodashIsEqual(value, other);
}
export function titleCase(value: string): string {
const words = value.split(/[-_]/);
return words.map((w) => w.charAt(0)?.toUpperCase() + w.slice(1)).join(" ");
@ -17,6 +26,55 @@ export function assertDefined<T>(value: T | undefined | null): asserts value is
}
}
type SourceToTargetFunction<T, R, K extends keyof R> = (value: T) => R[K];
type SourceToTarget<T, R, K extends keyof R> = K extends keyof T
? T[K] extends R[K]
? true | SourceToTargetFunction<T, R, K>
: SourceToTargetFunction<T, R, K>
: SourceToTargetFunction<T, R, K>;
type ObjectMapConfig<T, R> = { [K in keyof R]: SourceToTarget<T, R, K> };
/**
* Create an object of type R from source object of type T
* @param config An object describing how to map the values from the source to the new object.
* Using true for a key passes the value from the source object as-is (if possible),
* while a function will be called to transform the property. Config must include
* every non-optional key on the target object type.
*/
export function mapObject<T, R>(
value: T,
config: ObjectMapConfig<T, R>,
removeUndefined = false,
removeNull = false,
): R {
const resultEntries: [string, unknown][] = [];
for (const entry of Object.entries(config)) {
const key = entry[0];
const configSet = entry[1] as SourceToTarget<T, R, keyof R>;
if (configSet === true) {
resultEntries.push([key, value[key as keyof T]]);
} else {
resultEntries.push([key, configSet(value)]);
}
}
let finalEntries: [string, unknown][] = [];
if (removeUndefined || removeNull) {
for (const [key, value] of resultEntries) {
if ((removeUndefined && value === undefined) || (removeNull && value === null)) {
continue;
}
finalEntries.push([key, value]);
}
} else {
finalEntries = [...resultEntries];
}
return Object.fromEntries(finalEntries) as R;
}
export function sanitizeObject<T>(params: T, usableFieldMap: Record<keyof T, boolean>): T {
const usableFields: (keyof T)[] = Object.entries(usableFieldMap)
.filter(([_, value]) => value)
@ -57,6 +115,7 @@ export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>)
// Handle parsing with try / catch
let parsed: T;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsed = JSON.parse(value);
} catch (err) {
return null;
@ -68,6 +127,7 @@ export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>)
for (const [_prop, include] of Object.entries(props)) {
const prop = _prop as keyof T;
if (include && parsed[prop]) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
result[prop] = parsed[prop];
}
}
@ -128,9 +188,9 @@ export class RequestError implements Error {
this.message = originalError.message;
// Request props
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
this.headers = (originalError as any).headers;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
this.status = (originalError as any).status;
}
}

View file

@ -281,21 +281,22 @@ body.theme-dark {
white-space: nowrap;
}
.github-link-table-refresh {
.github-link-table-footer {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
}
.github-link-table-refresh:has(a) {
justify-content: space-between;
.github-link-table-pagination {
display: flex;
gap: 2px;
}
.github-link-table-refresh-external-link {
.github-link-table-footer-external-link {
font-size: 0.75rem;
}
.github-link-table-refresh > button > svg {
.github-link-table-footer > button > svg {
width: var(--icon-s);
height: var(--icon-s);
stroke-width: var(--icon-s-stroke-width);

View file

@ -1,6 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",