430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
/**
|
||
* GitHub(github.com)上的 [ForgeProvider]:用 Node 自带的 `fetch` 调 REST 接口,删除工单这一项 REST 没有,走 GraphQL。
|
||
*
|
||
* 请求只发往 `api.github.com`,带 `gh auth token` 取来的 token;GitHub 的原始 JSON 只在这个文件里出现。
|
||
*/
|
||
|
||
import type {
|
||
ForgeCapabilities,
|
||
ForgeComment,
|
||
ForgeIssue,
|
||
ForgePrFile,
|
||
ForgeProvider,
|
||
ForgePullRequest,
|
||
ForgeUser,
|
||
ForgeUserRef,
|
||
IssueFilter,
|
||
MergeMethod,
|
||
MergeOutcome,
|
||
PrFileStatus,
|
||
} from '../types'
|
||
import { fetchImageDataUri } from '../avatars'
|
||
import { nextPageLink } from '../linkHeader'
|
||
import { ForgeApiError } from '../types'
|
||
|
||
const API = 'https://api.github.com'
|
||
const API_HOST = 'api.github.com'
|
||
/** 固定接口版本,GitHub 以后改了默认版本也不影响这里解析的字段。 */
|
||
const API_VERSION = '2022-11-28'
|
||
/** GitHub 列表接口每页上限。 */
|
||
const PAGE_SIZE = 100
|
||
const AVATAR_HOST = 'avatars.githubusercontent.com'
|
||
/** 卡片上的头像只有十几像素,要 64 像素的图在高分屏上也够清楚。 */
|
||
const AVATAR_SIZE = 64
|
||
|
||
interface RawUserRef {
|
||
login?: string
|
||
avatar_url?: string
|
||
}
|
||
|
||
interface RawIssue {
|
||
/** 内部 id,依赖接口按它认工单,不认编号。 */
|
||
id: number
|
||
/** GraphQL 用的全局 id。 */
|
||
node_id: string
|
||
number: number
|
||
title: string
|
||
state: 'open' | 'closed'
|
||
comments: number
|
||
html_url: string
|
||
repository_url?: string
|
||
closed_at?: string | null
|
||
user?: RawUserRef | null
|
||
assignees?: RawUserRef[] | null
|
||
/** 有这个键的是 PR:GitHub 的工单接口把 PR 也当工单返回。 */
|
||
pull_request?: unknown
|
||
}
|
||
|
||
interface RawComment {
|
||
id: number
|
||
body?: string | null
|
||
created_at?: string
|
||
user?: RawUserRef | null
|
||
}
|
||
|
||
interface RawPullRequest {
|
||
number: number
|
||
state: 'open' | 'closed'
|
||
merged?: boolean
|
||
merged_at?: string | null
|
||
html_url: string
|
||
body?: string | null
|
||
head: { sha: string, ref: string }
|
||
base: { sha: string, ref: string }
|
||
}
|
||
|
||
interface RawPrFile {
|
||
filename: string
|
||
previous_filename?: string
|
||
status?: string
|
||
additions?: number
|
||
deletions?: number
|
||
}
|
||
|
||
/** GitHub 没有 Gitea 那种「变基后创建合并提交」。 */
|
||
const CAPABILITIES: ForgeCapabilities = {
|
||
deleteIssue: true,
|
||
dependencies: true,
|
||
mergeMethods: ['merge', 'squash', 'rebase'],
|
||
}
|
||
|
||
/** GitHub 的文件改动状态有 added、removed、modified、renamed、copied、changed、unchanged;changed 与 unchanged 都算修改。 */
|
||
function fileStatus(status: string | undefined): PrFileStatus {
|
||
switch (status) {
|
||
case 'added':
|
||
case 'renamed':
|
||
case 'copied':
|
||
return status
|
||
case 'removed':
|
||
return 'deleted'
|
||
default:
|
||
return 'modified'
|
||
}
|
||
}
|
||
|
||
function userRef(raw: RawUserRef): ForgeUserRef {
|
||
return {
|
||
login: raw.login ?? '',
|
||
...(raw.avatar_url ? { avatarUrl: raw.avatar_url } : {}),
|
||
}
|
||
}
|
||
|
||
function toIssue(raw: RawIssue): ForgeIssue {
|
||
return {
|
||
number: raw.number,
|
||
title: raw.title,
|
||
state: raw.state,
|
||
htmlUrl: raw.html_url,
|
||
commentCount: raw.comments,
|
||
...(raw.closed_at ? { closedAt: raw.closed_at } : {}),
|
||
...(raw.user ? { author: userRef(raw.user) } : {}),
|
||
assignees: (raw.assignees ?? []).map(userRef),
|
||
}
|
||
}
|
||
|
||
function toComment(raw: RawComment): ForgeComment {
|
||
return {
|
||
id: raw.id,
|
||
body: raw.body ?? '',
|
||
createdAt: raw.created_at ?? '',
|
||
...(raw.user ? { author: userRef(raw.user) } : {}),
|
||
}
|
||
}
|
||
|
||
function toUser(raw: { login: string, email?: string | null }): ForgeUser {
|
||
return { login: raw.login, ...(raw.email ? { email: raw.email } : {}) }
|
||
}
|
||
|
||
/** 文件路径、分支名逐段转义后再拼进地址,保留其中的 `/`。 */
|
||
function encodePath(p: string): string {
|
||
return p.split('/').map(encodeURIComponent).join('/')
|
||
}
|
||
|
||
/** GitHub 的错误响应是 `{ "message": … }`,取出 message;不是这个格式就用原文。 */
|
||
function errorText(body: string): string {
|
||
try {
|
||
const message = (JSON.parse(body) as { message?: unknown }).message
|
||
if (typeof message === 'string' && message)
|
||
return message
|
||
}
|
||
catch {}
|
||
return body
|
||
}
|
||
|
||
export class GitHubProvider implements ForgeProvider {
|
||
readonly kind = 'github' as const
|
||
readonly capabilities = CAPABILITIES
|
||
|
||
private readonly owner: string
|
||
private readonly repo: string
|
||
private readonly token: string
|
||
|
||
constructor(opts: { owner: string, repo: string, token: string }) {
|
||
this.owner = opts.owner
|
||
this.repo = opts.repo
|
||
this.token = opts.token
|
||
}
|
||
|
||
private get repoUrl(): string {
|
||
return `${API}/repos/${this.owner}/${this.repo}`
|
||
}
|
||
|
||
private headers(opts: { json?: boolean, accept?: string } = {}): Record<string, string> {
|
||
return {
|
||
'Authorization': `Bearer ${this.token}`,
|
||
'Accept': opts.accept ?? 'application/vnd.github+json',
|
||
'X-GitHub-Api-Version': API_VERSION,
|
||
...(opts.json ? { 'Content-Type': 'application/json' } : {}),
|
||
}
|
||
}
|
||
|
||
private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise<Response> {
|
||
const hasBody = init.body !== undefined
|
||
const res = await fetch(url, {
|
||
...(init.method ? { method: init.method } : {}),
|
||
headers: this.headers({ json: hasBody }),
|
||
...(hasBody ? { body: JSON.stringify(init.body) } : {}),
|
||
})
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new ForgeApiError(res.status, errorText(text) || res.statusText)
|
||
}
|
||
return res
|
||
}
|
||
|
||
private async getJson<T>(url: string): Promise<T> {
|
||
return await (await this.send(url)).json() as T
|
||
}
|
||
|
||
/**
|
||
* 一页 100 条,照 Link 头给的 rel="next" 地址一页页往下拉。
|
||
*
|
||
* @throws [Error] 下一页地址不在 `api.github.com` 上;跟过去会把 token 发给别的服务器
|
||
*/
|
||
private async paginate<T>(path: string, params: Record<string, string> = {}): Promise<T[]> {
|
||
let url = new URL(`${this.repoUrl}${path}`)
|
||
for (const [k, v] of Object.entries(params))
|
||
url.searchParams.set(k, v)
|
||
url.searchParams.set('per_page', String(PAGE_SIZE))
|
||
const out: T[] = []
|
||
for (;;) {
|
||
const res = await this.send(url.toString())
|
||
const batch = await res.json() as T[]
|
||
if (!Array.isArray(batch))
|
||
return out
|
||
out.push(...batch)
|
||
const next = nextPageLink(res)
|
||
if (!next)
|
||
return out
|
||
const nextUrl = new URL(next, url)
|
||
if (nextUrl.protocol !== 'https:' || nextUrl.host !== API_HOST)
|
||
throw new Error(`翻页地址指向 ${nextUrl.host},不是 ${API_HOST},为免泄露凭据已停止`)
|
||
url = nextUrl
|
||
}
|
||
}
|
||
|
||
private async rawIssue(issueNumber: number): Promise<RawIssue> {
|
||
return this.getJson<RawIssue>(`${this.repoUrl}/issues/${issueNumber}`)
|
||
}
|
||
|
||
/** 只认挂在本仓库下的工单;跨仓库的依赖编号在本仓库里指的是别的工单。 */
|
||
private inThisRepo(raw: RawIssue): boolean {
|
||
return !raw.repository_url || raw.repository_url.toLowerCase() === this.repoUrl.toLowerCase()
|
||
}
|
||
|
||
/** 公开邮箱没设时 GitHub 给 `null`,这时不带邮箱。 */
|
||
async getCurrentUser(): Promise<ForgeUser> {
|
||
return toUser(await this.getJson<{ login: string, email?: string | null }>(`${API}/user`))
|
||
}
|
||
|
||
async listAssignees(): Promise<ForgeUser[]> {
|
||
return (await this.paginate<{ login: string }>('/assignees')).map(toUser)
|
||
}
|
||
|
||
async listIssues(filter: IssueFilter = {}): Promise<ForgeIssue[]> {
|
||
const params: Record<string, string> = { state: 'all' }
|
||
if (filter.assignee)
|
||
params.assignee = filter.assignee
|
||
if (filter.creator)
|
||
params.creator = filter.creator
|
||
const raws = await this.paginate<RawIssue>('/issues', params)
|
||
return raws.filter(raw => raw.pull_request === undefined).map(toIssue)
|
||
}
|
||
|
||
/** 工单不存在(404)、已被删除(GitHub 回 410)或这个编号其实是 PR 时都返回 `null`。 */
|
||
async getIssue(issueNumber: number): Promise<ForgeIssue | null> {
|
||
try {
|
||
const raw = await this.rawIssue(issueNumber)
|
||
return raw.pull_request === undefined ? toIssue(raw) : null
|
||
}
|
||
catch (err) {
|
||
if (err instanceof ForgeApiError && (err.status === 404 || err.status === 410))
|
||
return null
|
||
throw err
|
||
}
|
||
}
|
||
|
||
async setIssueAssignees(issueNumber: number, logins: string[]): Promise<void> {
|
||
await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { assignees: logins } })
|
||
}
|
||
|
||
async closeIssue(issueNumber: number): Promise<void> {
|
||
await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { state: 'closed' } })
|
||
}
|
||
|
||
/**
|
||
* REST 没有删除工单的接口,用 GraphQL 的 `deleteIssue`,它要工单的 `node_id`;只有仓库管理员能删。
|
||
*
|
||
* @throws [ForgeApiError] 没有权限时 `status` 是 403;GraphQL 返回其它错误时是 422
|
||
*/
|
||
async deleteIssue(issueNumber: number): Promise<void> {
|
||
const { node_id } = await this.rawIssue(issueNumber)
|
||
const res = await this.send(`${API}/graphql`, {
|
||
method: 'POST',
|
||
body: {
|
||
query: 'mutation($id: ID!) { deleteIssue(input: { issueId: $id }) { clientMutationId } }',
|
||
variables: { id: node_id },
|
||
},
|
||
})
|
||
// GraphQL 出错时 HTTP 状态照样是 200,错误放在 errors 里。
|
||
const { errors } = await res.json() as { errors?: Array<{ type?: string, message?: string }> }
|
||
if (!errors?.length)
|
||
return
|
||
if (errors.some(e => e.type === 'FORBIDDEN'))
|
||
throw new ForgeApiError(403, '需要仓库管理员权限才能删除工单')
|
||
throw new ForgeApiError(422, errors.map(e => e.message ?? e.type ?? '').join(';'))
|
||
}
|
||
|
||
async listIssueComments(issueNumber: number): Promise<ForgeComment[]> {
|
||
return (await this.paginate<RawComment>(`/issues/${issueNumber}/comments`)).map(toComment)
|
||
}
|
||
|
||
async postIssueComment(issueNumber: number, body: string): Promise<void> {
|
||
await this.send(`${this.repoUrl}/issues/${issueNumber}/comments`, { method: 'POST', body: { body } })
|
||
}
|
||
|
||
async listDependencies(issueNumber: number): Promise<number[]> {
|
||
const blockers = await this.paginate<RawIssue>(`/issues/${issueNumber}/dependencies/blocked_by`)
|
||
return blockers.filter(raw => this.inThisRepo(raw)).map(raw => raw.number)
|
||
}
|
||
|
||
/** 依赖接口按被依赖工单的内部 `id` 认人,先按编号查出 id。 */
|
||
async addDependency(issueNumber: number, dependsOn: number): Promise<void> {
|
||
const { id } = await this.rawIssue(dependsOn)
|
||
await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies/blocked_by`, { method: 'POST', body: { issue_id: id } })
|
||
}
|
||
|
||
async removeDependency(issueNumber: number, dependsOn: number): Promise<void> {
|
||
const { id } = await this.rawIssue(dependsOn)
|
||
await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies/blocked_by/${id}`, { method: 'DELETE' })
|
||
}
|
||
|
||
async getPullRequest(prNumber: number): Promise<ForgePullRequest> {
|
||
const raw = await this.getJson<RawPullRequest>(`${this.repoUrl}/pulls/${prNumber}`)
|
||
return {
|
||
number: raw.number,
|
||
state: raw.state === 'open' ? 'open' : 'closed',
|
||
merged: raw.merged === true,
|
||
...(raw.merged_at ? { mergedAt: raw.merged_at } : {}),
|
||
htmlUrl: raw.html_url,
|
||
body: raw.body ?? '',
|
||
headSha: raw.head.sha,
|
||
headRef: raw.head.ref,
|
||
baseSha: raw.base.sha,
|
||
baseRef: raw.base.ref,
|
||
}
|
||
}
|
||
|
||
/** PR 详情里没有合并基,要另调 compare 接口;它还会带回提交列表,只要 1 条,少传点数据。 */
|
||
async pullRequestDiffBase(pr: ForgePullRequest): Promise<string> {
|
||
try {
|
||
const url = new URL(`${this.repoUrl}/compare/${pr.baseSha}...${pr.headSha}`)
|
||
url.searchParams.set('per_page', '1')
|
||
const data = await this.getJson<{ merge_base_commit?: { sha?: string } }>(url.toString())
|
||
return data.merge_base_commit?.sha || pr.baseSha
|
||
}
|
||
catch {
|
||
return pr.baseSha
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GitHub 不能合并时回 405:说「not mergeable」的是有冲突,其余(合并方式被仓库禁用、必需的检查没过等)算被拒;
|
||
* 回 409 是合并期间 head 又变了。
|
||
*/
|
||
async mergePullRequest(prNumber: number, method: MergeMethod): Promise<MergeOutcome> {
|
||
if (!CAPABILITIES.mergeMethods.includes(method))
|
||
return { merged: false, reason: 'rejected', message: `GitHub 不支持 ${method} 合并方式` }
|
||
try {
|
||
await this.send(`${this.repoUrl}/pulls/${prNumber}/merge`, { method: 'PUT', body: { merge_method: method } })
|
||
return { merged: true }
|
||
}
|
||
catch (err) {
|
||
if (!(err instanceof ForgeApiError))
|
||
throw err
|
||
if (err.status === 405)
|
||
return { merged: false, reason: /not mergeable|conflict/i.test(err.message) ? 'conflict' : 'rejected', message: err.message }
|
||
return { merged: false, reason: err.status === 409 ? 'head-changed' : 'rejected', message: err.message }
|
||
}
|
||
}
|
||
|
||
async closePullRequest(prNumber: number): Promise<void> {
|
||
await this.send(`${this.repoUrl}/pulls/${prNumber}`, { method: 'PATCH', body: { state: 'closed' } })
|
||
}
|
||
|
||
async listPullRequestFiles(prNumber: number): Promise<ForgePrFile[]> {
|
||
const files = await this.paginate<RawPrFile>(`/pulls/${prNumber}/files`)
|
||
return files.map(f => ({
|
||
path: f.filename,
|
||
status: fileStatus(f.status),
|
||
additions: f.additions ?? 0,
|
||
deletions: f.deletions ?? 0,
|
||
...(f.previous_filename ? { previousFilename: f.previous_filename } : {}),
|
||
}))
|
||
}
|
||
|
||
/** 文件在该 ref 上不存在(404)是新增、删除文件的正常情况,返回空串让 diff 显示成整份新增或删除;其它失败也返回空串。 */
|
||
async getRawFile(path: string, ref: string): Promise<string> {
|
||
const url = new URL(`${this.repoUrl}/contents/${encodePath(path)}`)
|
||
url.searchParams.set('ref', ref)
|
||
try {
|
||
const res = await fetch(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) })
|
||
return res.ok ? await res.text() : ''
|
||
}
|
||
catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
async deleteBranch(branch: string): Promise<void> {
|
||
await this.send(`${this.repoUrl}/git/refs/heads/${encodePath(branch)}`, { method: 'DELETE' })
|
||
}
|
||
|
||
/**
|
||
* 头像在 `avatars.githubusercontent.com` 这类公开域名上,不带 token;只有 `api.github.com` 上的地址才带。
|
||
*
|
||
* 头像域名上的图按 [AVATAR_SIZE] 要小图:用户上传的原图可能超过 [头像大小上限](vscode/src/forge/avatars.ts#MAX_BYTES),抓回来也会被丢掉。
|
||
*/
|
||
avatarDataUri(url: string): Promise<string | undefined> {
|
||
let parsed: URL
|
||
try {
|
||
parsed = new URL(url)
|
||
}
|
||
catch {
|
||
return Promise.resolve(undefined)
|
||
}
|
||
const host = parsed.host.toLowerCase()
|
||
if (host === AVATAR_HOST)
|
||
parsed.searchParams.set('s', String(AVATAR_SIZE))
|
||
return fetchImageDataUri(parsed.toString(), host === API_HOST ? { Authorization: `Bearer ${this.token}` } : {})
|
||
}
|
||
|
||
issueWebUrl(issueNumber: number): string {
|
||
return `https://github.com/${this.owner}/${this.repo}/issues/${issueNumber}`
|
||
}
|
||
|
||
pullRequestWebUrl(prNumber: number): string {
|
||
return `https://github.com/${this.owner}/${this.repo}/pull/${prNumber}`
|
||
}
|
||
}
|