mirror of
https://github.com/nathonius/obsidian-github-link.git
synced 2026-07-22 09:20:25 +00:00
Merge pull request #120 from nathonius/feat/102/table-pagination
Feat/102/table pagination
This commit is contained in:
commit
cb64714466
29 changed files with 2137 additions and 547 deletions
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
main.ts
2
main.ts
|
|
@ -1,2 +1,2 @@
|
|||
import { GithubLinkPlugin } from "src/plugin";
|
||||
import { GithubLinkPlugin } from "./src/plugin";
|
||||
export default GithubLinkPlugin;
|
||||
|
|
|
|||
1310
package-lock.json
generated
1310
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
225
src/query/query.ts
Normal 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
32
src/query/sort.ts
Normal 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
102
src/query/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
64
src/util.ts
64
src/util.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
styles.css
13
styles.css
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
|
|
|
|||
Loading…
Reference in a new issue