Files
superwork/vscode/src/forge/github/provider.ts
T

430 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`
}
}