From 22191fc31cc8691850cc38476b7f1b3028849310 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 1 May 2022 23:49:35 +0800 Subject: [PATCH] feat: resolve GitHub commit page --- src/global.d.ts | 2 +- src/platforms/GitHub/API.ts | 84 +++++++++++++++++++++++------ src/platforms/GitHub/DOMHelper.ts | 5 ++ src/platforms/GitHub/Request.d.ts | 85 +++++++++++++++++++++++++++-- src/platforms/GitHub/URLHelper.ts | 13 +++-- src/platforms/GitHub/index.ts | 89 +++++++++++++++++++++++++++++-- src/platforms/GitHub/utils.ts | 75 ++++++++++++++++++++++++++ 7 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 src/platforms/GitHub/utils.ts diff --git a/src/global.d.ts b/src/global.d.ts index 6015caa..668028f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -3,7 +3,7 @@ type MetaData = { repoName: string defaultBranchName: string branchName: string - type?: EnumString<'tree' | 'blob' | 'pull'> + type?: EnumString<'tree' | 'blob' | 'pull' | 'commit'> } type PartialMetaData = Omit, 'defaultBranchName'> diff --git a/src/platforms/GitHub/API.ts b/src/platforms/GitHub/API.ts index ffa2911..3ab9af3 100644 --- a/src/platforms/GitHub/API.ts +++ b/src/platforms/GitHub/API.ts @@ -17,7 +17,9 @@ async function request( url: string, { accessToken, + resolveMode = 'body-json', }: { + resolveMode?: 'body-json' | 'response' accessToken?: string } = {}, ) { @@ -41,21 +43,28 @@ async function request( // True if res.status between 200~299 // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok if (res.ok) { - if (isJson) return res.json() - throw new Error(`Response content type is "${contentType}"`) - } else { - if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND) - else if (res.status === 403) throw new Error(errors.API_RATE_LIMIT) - else if (res.status === 500) throw new Error(errors.SERVER_FAULT) - else if (isJson) { - const content = await res.json() - if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT) - if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT) - if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT) - throw new Error(`Unknown message content "${content?.message}"`) - } else { - throw new Error(`Response content type is "${contentType}"`) + switch (resolveMode) { + case 'body-json': { + if (isJson) return res.json() + throw new Error(`Response content type is "${contentType}"`) + } + case 'response': + return res } + throw new Error(`Unknown resolve mode: ${resolveMode}`) + } + + if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND) + else if (res.status === 403) throw new Error(errors.API_RATE_LIMIT) + else if (res.status === 500) throw new Error(errors.SERVER_FAULT) + else if (resolveMode && isJson) { + const content = await res.json() + if (apiRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT) + if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT) + if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT) + throw new Error(`Unknown message content "${content?.message}"`) + } else { + throw new Error(`Response content type is "${contentType}"`) } } @@ -119,7 +128,7 @@ export async function getPullComments( export async function getPullPageDocuments( userName: string, repoName: string, - pullId: string, // not used + pullId: string, ): Promise { // Response of this API contains view of few files but is not complete. const filesDOM = await getDOM( @@ -155,6 +164,36 @@ export async function getPullPageDocuments( return diffsDOMs } +export async function getCommitPageDocuments( + userName: string, + repoName: string, + commitId: string, +): Promise { + /** + * + */ + const fragmentSelector = 'include-fragment[data-targets="diff-file-filter.progressiveLoaders"]' + + let doc = document + const documents: Document[] = [doc] + while (true) { + const fragment = doc.querySelector(fragmentSelector) as HTMLElement + if (!fragment) break + const src = fragment.getAttribute('src') + if (!src) break + const nextDoc = await getDOM(src) + documents.push(nextDoc) + doc = nextDoc + } + + return documents +} + async function getDOM(url: string) { return new DOMParser().parseFromString(await (await fetch(url)).text(), 'text/html') } @@ -185,3 +224,18 @@ export async function OAuth(code: string): Promise { return null } } + +export async function getCommitTreeData( + userName: string, + repoName: string, + sha: string, + page: number = 1, + accessToken?: string, +): Promise { + const search = new URLSearchParams({ + per_page: '100', + page: `${page}`, + }) + const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/commits/${sha}?` + search + return await request(url, { accessToken, resolveMode: 'response' }) +} diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts index 32478b7..0570899 100644 --- a/src/platforms/GitHub/DOMHelper.ts +++ b/src/platforms/GitHub/DOMHelper.ts @@ -36,6 +36,11 @@ export function getIssueTitle() { return title?.trim().replace(/\n/g, '') } +export function getCommitTitle() { + const title = $('.commit-title')?.textContent + return title?.trim().replace(/\n/g, '') +} + export function getCurrentBranch(passive = false) { const selectedBranchButtonSelector = [ '.repository-content #branch-select-menu summary', diff --git a/src/platforms/GitHub/Request.d.ts b/src/platforms/GitHub/Request.d.ts index 3ee31cb..3100962 100644 --- a/src/platforms/GitHub/Request.d.ts +++ b/src/platforms/GitHub/Request.d.ts @@ -15,19 +15,98 @@ declare namespace GitHubAPI { url: string } - type PullTreeItem = { + type User = { + login: string + id: number + node_id: string + avatar_url: string + gravatar_id: string + url: string + html_url: string + followers_url: string + following_url: string + gists_url: string + starred_url: string + subscriptions_url: string + organizations_url: string + repos_url: string + events_url: string + received_events_url: string + type: 'User' | string + site_admin: boolean + } + + type Commit = { + url: string + author: { + name: string + email: string + date: string + } + committer: { + name: string + email: string + date: string + } + message: string + tree: { + url: string + sha: string + } + comment_count: number + verification: { + verified: boolean + reason: 'unsigned' | string + signature: null + payload: null + } + } + + type CommitResponseData = { + url: string + sha: string + node_id: string + html_url: string + comments_url: string + commit: Commit + author: User + committer: User + parents: { + url: string + sha: string + }[] + stats: { + additions: number + deletions: number + total: number + } + files: CommitTreeItem[] + } + + type CommitTreeItem = { additions: number blob_url: string changes: number - contents_url: string deletions: number filename: string patch: string raw_url: string - sha: string status: 'modified' | 'added' | 'removed' | 'renamed' } + type PullTreeItem = { + additions: number + blob_url: string + changes: number + deletions: number + filename: string + patch: string + raw_url: string + status: 'modified' | 'added' | 'removed' | 'renamed' + sha: string + contents_url: string + } + type PullData = { state: 'open' | 'closed' title: string diff --git a/src/platforms/GitHub/URLHelper.ts b/src/platforms/GitHub/URLHelper.ts index 229317d..a98cc6f 100644 --- a/src/platforms/GitHub/URLHelper.ts +++ b/src/platforms/GitHub/URLHelper.ts @@ -24,7 +24,7 @@ export function parse(): Partial, + commitSHA: string, + accessToken: string | undefined, +) { + const treeData: GitHubAPI.CommitTreeItem[] = [] + let page = 1 + while (true) { + const response = await API.getCommitTreeData(userName, repoName, commitSHA, page, accessToken) + const { files } = (await response.json()) as GitHubAPI.CommitResponseData + treeData.push(...files) + + const headerLink = response.headers.get('link') + if (headerLink) { + const rels = resolveHeaderLink(headerLink) + if (rels) { + if (rels.position === 'first') { + page++ + } else if (rels.position === 'middle') { + const searchOfLast = new URL(rels.last).searchParams + if (`${page}` === searchOfLast.get('page')) { + // this should not actually happen because GitHub responds `prev` and `first` for the first page + break + } + page++ + } else { + // i.e. rels.position === 'last' + break + } + } else { + // unexpected link header content + break + } + } else { + // no link headers if there is <100 files + break + } + } + + const documents = await API.getCommitPageDocuments(userName, repoName, commitSHA) + + const getItemURL = (path: string) => { + for (const doc of documents) { + const id = doc.querySelector(`[data-path="${path}"]`)?.parentElement?.id + if (id) return `#${id}` + } + } + + const root = processTree( + treeData.map(item => ({ + type: 'blob', + path: item.filename, + name: item.filename.replace(/^.*\//, ''), + url: getItemURL(item.filename) || item.blob_url, + sha: item.patch, + diff: { + status: item.status, + additions: item.additions, + deletions: item.deletions, + changes: item.changes, + }, + })), + ) + + return { root } +} + async function getPullRequestTreeData( { userName, repoName }: Pick, pullId: string, diff --git a/src/platforms/GitHub/utils.ts b/src/platforms/GitHub/utils.ts new file mode 100644 index 0000000..d112f82 --- /dev/null +++ b/src/platforms/GitHub/utils.ts @@ -0,0 +1,75 @@ +/** + * Resolved from response header `link` + * + * Example: + * ; rel="next", ; rel="last" + * + * `rel` existence + * + * rel | first page | middle page | last page + * next | ✔ | ✔ | + * last | ✔ | ✔ | + * prev | | ✔ | ✔ + * first| | ✔ | ✔ + * + * If there is only 1 page, no `link` header is returned. + */ +type Rels = { + next?: string + last?: string + prev?: string + first?: string +} + +export function resolveHeaderLink(raw: string) { + const rels: Rels = {} + raw + .split(',') + .map(part => part.match(/<(.*?)>; *rel="(.*?)"/)) + .filter((link: RegExpMatchArray | null): link is RegExpMatchArray => !!link) + .forEach(([, url, rel]) => { + // It's 2022, is there a smarter way to do this in TS? + switch (rel) { + case 'next': + rels.next = url + break + case 'last': + rels.last = url + break + case 'prev': + rels.prev = url + break + case 'first': + rels.first = url + break + } + }) + + if (rels.next && rels.last && !rels.prev && !rels.first) { + // first page + return { + next: rels.next, + last: rels.last, + position: 'first' as const, + } + } else if (rels.next && rels.last && rels.prev && rels.first) { + // middle page + return { + next: rels.next, + last: rels.last, + prev: rels.prev, + first: rels.first, + position: 'middle' as const, + } + } else if (!rels.next && !rels.last && rels.prev && rels.first) { + // last page + return { + prev: rels.prev, + first: rels.first, + position: 'last' as const, + } + } else { + // unexpected link header content + return + } +}