Skip to content

Commit

Permalink
Merge pull request #26 from nathonius/issue-columns
Browse files Browse the repository at this point in the history
✨ feat: #17 Add special issue columns
  • Loading branch information
nathonius authored Feb 8, 2024
2 parents 33755f8 + 0d9fad9 commit 3023f21
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 55 deletions.
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"@octokit/auth-oauth-device": "^6.0.1",
"@octokit/core": "^5.0.2",
"@octokit/openapi-types": "^19.1.0",
"@octokit/plugin-rest-endpoint-methods": "^10.2.0",
"@octokit/request": "^8.1.6"
}
Expand Down
4 changes: 2 additions & 2 deletions src/github/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { CodeResponse, IssueResponse, PullResponse, SearchIssueResponse, Se
import type { RequestUrlParam } from "obsidian";
import { requestUrl } from "obsidian";

const debug = false;
const debug = true;

const baseApi = "https://api.github.com";

async function githubRequest(config: RequestUrlParam, token?: string) {
export async function githubRequest(config: RequestUrlParam, token?: string) {
if (!config.headers) {
config.headers = {};
}
Expand Down
11 changes: 10 additions & 1 deletion src/github/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,24 @@ class OrgCache {
}

export class Cache {
public readonly generic: Record<string, CacheEntry<unknown>> = {};
public readonly orgs: Record<string, OrgCache> = {};
public readonly queries = new QueryCache();

getGeneric(url: string): unknown | null {
return this.getCacheValue(this.generic[url] ?? null);
}

setGeneric(url: string, value: unknown): void {
this.generic[url] = new CacheEntry(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) {
setIssue(org: string, repo: string, issue: IssueResponse): void {
const issueCache = this.getRepoCache(org, repo).issueCache;
const existingCache = issueCache[issue.id];
if (existingCache) {
Expand Down
28 changes: 26 additions & 2 deletions src/github/github.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { IssueResponse, PullResponse, SearchIssueResponse, SearchRepoResponse } from "./response";
import type {
IssueResponse,
IssueTimelineResponse,
PullResponse,
SearchIssueResponse,
SearchRepoResponse,
TimelineCrossReferencedEvent,
} from "./response";

import { Cache } from "./cache";
import { PluginSettings } from "src/plugin";
import { api } from "./api";
import { api, githubRequest } from "./api";

const cache = new Cache();

Expand Down Expand Up @@ -56,3 +63,20 @@ export async function searchRepos(query: string, org?: string): Promise<SearchRe
cache.setRepoQuery(query, response);
return response;
}

export async function getPRForIssue(timelineUrl: string, org?: string) {
let response = cache.getGeneric(timelineUrl) as IssueTimelineResponse | null;
if (response === null) {
response = (await githubRequest({ url: timelineUrl }, getToken(org))).json;
}
if (!response) {
return null;
}
cache.setGeneric(timelineUrl, response);
// TODO: Figure out a better/more reliable way to do this.
const crossRefEvent = response.find((_evt) => {
const evt = _evt as Partial<TimelineCrossReferencedEvent>;
return evt.event === "cross-referenced" && evt.source?.issue?.pull_request?.html_url;
}) as TimelineCrossReferencedEvent | undefined;
return crossRefEvent?.source.issue?.pull_request?.html_url ?? null;
}
20 changes: 15 additions & 5 deletions src/github/response.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import type * as OpenAPI from "@octokit/openapi-types";

export enum IssueStatus {
Open = "open",
Closed = "closed",
Done = "done",
}

// Response Types
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
export type IssueListResponse = RestEndpointMethodTypes["issues"]["list"]["response"]["data"];
export type PullResponse = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
export type PullListResponse = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
export type CodeResponse = RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
export type SearchRepoParams = RestEndpointMethodTypes["search"]["repos"]["parameters"];
export type SearchRepoResponse = RestEndpointMethodTypes["search"]["repos"]["response"]["data"];
export type SearchIssueParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
export type SearchIssueResponse = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"];
export type TimelineCrossReferencedEvent = OpenAPI.components["schemas"]["timeline-cross-referenced-event"];

// Param Types
export type SearchRepoParams = RestEndpointMethodTypes["search"]["repos"]["parameters"];
export type ListIssueParams = RestEndpointMethodTypes["issues"]["list"]["parameters"];
export type ListPullParams = RestEndpointMethodTypes["pulls"]["list"]["parameters"];
export type SearchIssueParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
export type IssueTimelineResponse = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"];

export function getSearchResultPRStatus(pr: SearchIssueResponse["items"][number]): IssueStatus {
if (pr.pull_request?.merged_at) {
export function getSearchResultIssueStatus(issue: SearchIssueResponse["items"][number]): IssueStatus {
if (issue.pull_request?.merged_at || issue.state_reason === "completed") {
return IssueStatus.Done;
} else if (pr.closed_at) {
} else if (issue.closed_at || issue.state === "closed") {
return IssueStatus.Closed;
} else {
return IssueStatus.Open;
Expand Down
9 changes: 9 additions & 0 deletions src/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ export function setPRIcon(icon: HTMLElement, status: IssueStatus) {
}
icon.dataset.status = status;
}

export function setIssueIcon(icon: HTMLElement, status: IssueStatus, reason: string | null | undefined) {
if (reason === "not_planned") {
setIcon(icon, "square-slash");
} else {
setIcon(icon, "square-dot");
}
icon.dataset.status = status;
}
27 changes: 27 additions & 0 deletions src/query/column/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SearchIssueResponse } from "src/github/response";
import { parseUrl, repoAPIToBrowserUrl } from "src/github/url-parse";
import { DateFormat } from "src/util";

export interface ColumnGetter<T> {
Expand Down Expand Up @@ -27,6 +28,32 @@ export function DateCell(value: string | undefined | null, el: HTMLTableCellElem
* Issue and PR columns share types, so some columns are shared
*/
export const CommonIssuePRColumns: ColumnsMap<SearchIssueResponse["items"][number]> = {
number: {
header: "Number",
cell: (row, el) => {
el.classList.add("github-link-table-issue-number");
el.createEl("a", { text: `#${row.number}`, href: row.html_url });
},
},
repo: {
header: "Repo",
cell: (row, el) => {
el.classList.add("github-link-table-repo");
const url = repoAPIToBrowserUrl(row.repository_url);
const parsed = parseUrl(url);
el.createEl("a", { text: parsed.repo, href: url });
},
},
author: {
header: "Author",
cell: (row, el) => {
const anchor = el.createEl("a", { cls: "github-link-table-author" });
if (row.user?.avatar_url) {
anchor.createEl("img", { cls: "github-link-table-avatar", attr: { src: row.user.avatar_url } });
}
anchor.createSpan({ text: row.user?.login });
},
},
created: {
header: "Created",
cell: (row, el) => {
Expand Down
35 changes: 33 additions & 2 deletions src/query/column/issue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,35 @@
import type { SearchIssueResponse } from "src/github/response";
import { getSearchResultIssueStatus, type SearchIssueResponse } from "src/github/response";
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<SearchIssueResponse["items"][number]> = { ...CommonIssuePRColumns };
export const IssueColumns: ColumnsMap<SearchIssueResponse["items"][number]> = {
...CommonIssuePRColumns,
status: {
header: "Status",
cell: (row, el) => {
const wrapper = el.createDiv({ cls: "github-link-table-status" });
const status = getSearchResultIssueStatus(row);
const icon = wrapper.createSpan({ cls: "github-link-status-icon" });
setIssueIcon(icon, status, row.state_reason);
wrapper.createSpan({ text: row.state_reason === "not_planned" ? "Not Planned" : titleCase(status) });
},
},
pr: {
header: "PR",
cell: async (row, el) => {
// TODO: Figure out how to include org here for private repos
if (!row.timeline_url) {
return;
}
const pullRequestUrl = await getPRForIssue(row.timeline_url);
if (!pullRequestUrl) {
return;
}
const tag = await createTag(pullRequestUrl);
el.appendChild(tag);
},
},
};
31 changes: 2 additions & 29 deletions src/query/column/pull-request.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,15 @@
import { getSearchResultPRStatus, IssueStatus, type SearchIssueResponse } from "src/github/response";
import { parseUrl, repoAPIToBrowserUrl } from "src/github/url-parse";
import { getSearchResultIssueStatus, IssueStatus, type SearchIssueResponse } from "src/github/response";
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
import { setPRIcon } from "src/icon";
import { titleCase } from "src/util";

export const PullRequestColumns: ColumnsMap<SearchIssueResponse["items"][number]> = {
...CommonIssuePRColumns,
number: {
header: "PR",
cell: (row, el) => {
el.classList.add("github-link-table-pr");
el.createEl("a", { text: `#${row.number}`, href: row.html_url });
},
},
repo: {
header: "Repo",
cell: (row, el) => {
el.classList.add("github-link-table-repo");
const url = repoAPIToBrowserUrl(row.repository_url);
const parsed = parseUrl(url);
el.createEl("a", { text: parsed.repo, href: url });
},
},
author: {
header: "Author",
cell: (row, el) => {
const anchor = el.createEl("a", { cls: "github-link-table-author" });
if (row.user?.avatar_url) {
anchor.createEl("img", { cls: "github-link-table-avatar", attr: { src: row.user.avatar_url } });
}
anchor.createSpan({ text: row.user?.login });
},
},
status: {
header: "Status",
cell: (row, el) => {
const wrapper = el.createDiv({ cls: "github-link-table-status" });
const status = getSearchResultPRStatus(row);
const status = getSearchResultIssueStatus(row);
const icon = wrapper.createSpan({ cls: "github-link-status-icon" });
setPRIcon(icon, status);
wrapper.createSpan({ text: status === IssueStatus.Done ? "Merged" : titleCase(status) });
Expand Down
34 changes: 23 additions & 11 deletions src/query/params.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SearchIssueParams, SearchRepoParams } from "src/github/response";
import type { ListIssueParams, ListPullParams, SearchIssueParams, SearchRepoParams } from "src/github/response";

import { parseYaml } from "obsidian";

Expand All @@ -15,12 +15,13 @@ export enum QueryType {
export interface BaseParams {
outputType: OutputType;
queryType: QueryType;
query: string;
}

export type PullRequestParams = Omit<SearchIssueParams, "q"> & BaseParams;
export type IssueParams = Omit<SearchIssueParams, "q"> & BaseParams;
export type RepoParams = Omit<SearchRepoParams, "q"> & BaseParams;
export type PullRequestListParams = ListPullParams & BaseParams;
export type IssueListParams = ListIssueParams & BaseParams;
export type PullRequestSearchParams = Omit<SearchIssueParams, "q"> & BaseParams & { query: string };
export type IssueSearchParams = Omit<SearchIssueParams, "q"> & BaseParams & { query: string };
export type RepoSearchParams = Omit<SearchRepoParams, "q"> & BaseParams & { query: string };

export type TableParams = { columns: string[] } & BaseParams;

Expand All @@ -36,14 +37,25 @@ export function processParams(source: string): BaseParams | null {
return params ?? null;
}

export function isPullRequestParams(params: BaseParams): params is PullRequestParams {
return params.queryType === QueryType.PullRequest;
export function isSearchParams(
params: BaseParams,
): params is PullRequestSearchParams | IssueSearchParams | RepoSearchParams {
return Boolean((params as IssueSearchParams).query);
}
export function isIssueParams(params: BaseParams): params is IssueParams {
return params.queryType === QueryType.Issue;
export function isPullRequestListParams(params: BaseParams): params is PullRequestListParams {
return params.queryType === QueryType.PullRequest && !isPullRequestSearchParams(params);
}
export function isRepoParams(params: BaseParams): params is RepoParams {
return params.queryType === QueryType.Repo;
export function isPullRequestSearchParams(params: BaseParams): params is PullRequestSearchParams {
return params.queryType === QueryType.PullRequest && Boolean((params as PullRequestSearchParams)?.query);
}
export function isIssueListParams(params: BaseParams): params is IssueListParams {
return params.queryType === QueryType.Issue && !isIssueSearchParams(params);
}
export function isIssueSearchParams(params: BaseParams): params is IssueSearchParams {
return params.queryType === QueryType.Issue && Boolean((params as IssueSearchParams)?.query);
}
export function isRepoSearchParams(params: BaseParams): params is RepoSearchParams {
return params.queryType === QueryType.Repo && Boolean((params as RepoSearchParams)?.query);
}
export function isTableParams(params: BaseParams): params is TableParams {
return params.outputType === OutputType.Table;
Expand Down
4 changes: 2 additions & 2 deletions src/query/processor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type MarkdownPostProcessorContext } from "obsidian";
import { isPullRequestParams, isTableParams, processParams } from "./params";
import { isIssueSearchParams, isPullRequestSearchParams, isTableParams, processParams } from "./params";
import { renderTable } from "./output";
import { searchIssues } from "src/github/github";

Expand All @@ -17,7 +17,7 @@ export async function QueryProcessor(
}

if (isTableParams(params)) {
if (isPullRequestParams(params)) {
if (isPullRequestSearchParams(params) || isIssueSearchParams(params)) {
const response = await searchIssues(params.query);
renderTable(params, response, el);
}
Expand Down

0 comments on commit 3023f21

Please sign in to comment.