feat: resolve GitHub commit page

This commit is contained in:
EnixCoda 2022-05-01 23:49:35 +08:00
parent c17da0f1f2
commit 22191fc31c
7 changed files with 327 additions and 26 deletions

2
src/global.d.ts vendored
View file

@ -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<MakeOptional<MetaData, 'branchName'>, 'defaultBranchName'>

View file

@ -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<Document[]> {
// 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<Document[]> {
/**
* <include-fragment
* src="/EnixCoda/Gitako/diffs?bytes=444&amp;commentable=true&amp;commit=7de4488d7f00630512e0d494bab209004f2d4a58&amp;lines=202&amp;responsive=true&amp;sha1=022dd1736146a350f1564c40d28234973d47bafc&amp;sha2=7de4488d7f00630512e0d494bab209004f2d4a58&amp;start_entry=1&amp;sticky=false&amp;w=false"
* class="diff-progressive-loader js-diff-progressive-loader mb-4 d-flex flex-items-center flex-justify-center"
* data-targets="diff-file-filter.progressiveLoaders"
* data-action="include-fragment-replace:diff-file-filter#refilterAfterAsyncLoad"
* >
*/
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<string | null> {
return null
}
}
export async function getCommitTreeData(
userName: string,
repoName: string,
sha: string,
page: number = 1,
accessToken?: string,
): Promise<Response> {
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' })
}

View file

@ -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',

View file

@ -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

View file

@ -24,7 +24,7 @@ export function parse(): Partial<Pick<MetaData, 'userName' | 'repoName' | 'type'
// cannot handle '/' split branch name, should not use when possibly in branch page
export function parseSHA() {
const { type, path } = parse()
return type === 'blob' || type === 'tree' ? path[0] : undefined
return type === 'blob' || type === 'tree' || type === 'commit' ? path[0] : undefined
}
export function isInPullPage() {
@ -32,15 +32,20 @@ export function isInPullPage() {
return type === 'pull' ? path[0] : false
}
function isCommitPath(path: string[]) {
export function isInCommitPage() {
const { type, path } = parse()
return type === 'commit' ? path[0] : false
}
export function isCommitPath(path: string[]) {
return path[0] ? isCompleteCommitSHA(path[0]) : false
}
function isCompleteCommitSHA(sha: string) {
export function isCompleteCommitSHA(sha: string) {
return /^[abcdef0-9]{40}$/i.test(sha)
}
function isPossiblyCommitSHA(sha: string) {
export function isPossiblyCommitSHA(sha: string) {
return /^[abcdef0-9]+$/i.test(sha)
}

View file

@ -11,6 +11,7 @@ import { useGitHubAttachCopyFileButton } from './hooks/useGitHubAttachCopyFileBu
import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton'
import { useGitHubCodeFold } from './hooks/useGitHubCodeFold'
import * as URLHelper from './URLHelper'
import { resolveHeaderLink } from './utils'
export { useGitHubCodeFold } from './hooks/useGitHubCodeFold'
function processTree(tree: TreeNode[]): TreeNode {
@ -131,6 +132,8 @@ export const GitHub: Platform = {
let branchName
if (URLHelper.isInPullPage()) {
branchName = DOMHelper.getIssueTitle()
} else if (URLHelper.isInCommitPage()) {
branchName = DOMHelper.getCommitTitle() || metaFromURL.path[0]
} else if (
DOMHelper.isInCodePage() &&
!['releases', 'tags'].includes(type || '') // resolve sentry issue #-CK
@ -153,7 +156,12 @@ export const GitHub: Platform = {
const repoUrl = `https://${window.location.host}/${userName}/${repoName}`
const userUrl = `https://${window.location.host}/${userName}`
const pullId = URLHelper.isInPullPage()
const branchUrl = pullId ? `${repoUrl}/pull/${pullId}` : `${repoUrl}/tree/${branchName}`
const commitId = URLHelper.isInCommitPage()
const branchUrl = pullId
? `${repoUrl}/pull/${pullId}`
: commitId && URLHelper.isPossiblyCommitSHA(commitId)
? `${repoUrl}/tree/${commitId}`
: `${repoUrl}/tree/${branchName}`
return {
repoUrl,
userUrl,
@ -165,14 +173,22 @@ export const GitHub: Platform = {
if (pullId) {
return await getPullRequestTreeData(metaData, pullId, accessToken)
}
const commitId = URLHelper.isInCommitPage()
if (commitId) {
return await getCommitTreeData(metaData, commitId, accessToken)
}
return await getRepositoryTreeData(metaData, path, recursive, accessToken)
},
shouldShow() {
return Boolean(DOMHelper.isInCodePage() || (URLHelper.isInPullPage() && !DOMHelper.isNativePRFileTreeShown()))
return Boolean(
DOMHelper.isInCodePage() ||
URLHelper.isInCommitPage() ||
(URLHelper.isInPullPage() && !DOMHelper.isNativePRFileTreeShown()),
)
},
shouldExpandAll() {
return Boolean(URLHelper.isInPullPage())
return Boolean(URLHelper.isInPullPage() || URLHelper.isInCommitPage())
},
getCurrentPath(branchName) {
const pathFromURL = URLHelper.parse().path.join('/')
@ -279,6 +295,73 @@ async function getRepositoryTreeData(
return { root, defer: treeData.truncated }
}
async function getCommitTreeData(
{ userName, repoName }: Pick<MetaData, 'userName' | 'repoName' | 'branchName'>,
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<MetaData, 'userName' | 'repoName' | 'branchName'>,
pullId: string,

View file

@ -0,0 +1,75 @@
/**
* Resolved from response header `link`
*
* Example:
* <https://api.github.com/repositories/112069171/commits/7de4488d7f00630512e0d494bab209004f2d4a58?per_page=100&page=2>; rel="next", <https://api.github.com/repositories/112069171/commits/7de4488d7f00630512e0d494bab209004f2d4a58?per_page=100&page=2>; 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
}
}