✨ feat(vscode): 新增 GitHub provider,凭据借 gh 登录,删工单走 GraphQL、依赖按内部 id 换算
This commit is contained in:
1 parent
451bc91a6b
commit
7351b6247f
12 files changed
+1010
-47
No files matched your search
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** 这个编号是 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)
|
||||
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}`
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user