mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
feat: resolve GitHub commit page
This commit is contained in:
parent
c17da0f1f2
commit
22191fc31c
7 changed files with 327 additions and 26 deletions
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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&commentable=true&commit=7de4488d7f00630512e0d494bab209004f2d4a58&lines=202&responsive=true&sha1=022dd1736146a350f1564c40d28234973d47bafc&sha2=7de4488d7f00630512e0d494bab209004f2d4a58&start_entry=1&sticky=false&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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
85
src/platforms/GitHub/Request.d.ts
vendored
85
src/platforms/GitHub/Request.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
75
src/platforms/GitHub/utils.ts
Normal file
75
src/platforms/GitHub/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue