From 14bee9f505d19fc7c695a00dab6dbadea719d31e Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 19 Oct 2026 07:56:40 +1100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=89=98=E7=AE=A1=E4=BB=93=E5=BA=93=E6=8A=BD?= =?UTF-8?q?=E6=88=90=E7=BB=9F=E4=B8=80=E6=8E=A5=E5=8F=A3=EF=BC=8CGitea=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=90=AC=E5=88=B0=20provider=20=E5=90=8E?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E7=BF=BB=E9=A1=B5=E6=94=B9=E7=9C=8B=20Link?= =?UTF-8?q?=20=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/auth/identity.ts | 86 +- .../codeReader/review/changedFiles.test.ts | 19 +- vscode/src/codeReader/review/changedFiles.ts | 14 +- vscode/src/codeReader/review/pickList.test.ts | 2 +- vscode/src/codeReader/review/pickList.ts | 2 +- vscode/src/codeReader/review/reviewMode.ts | 32 +- vscode/src/codeReader/review/reviewRules.ts | 2 +- vscode/src/codeReader/review/reviewSource.ts | 38 +- .../src/codeReader/review/reviewWorktree.ts | 6 +- vscode/src/{gitea => forge}/avatars.ts | 24 +- vscode/src/forge/context.test.ts | 86 ++ vscode/src/forge/context.ts | 81 ++ vscode/src/forge/gitea/provider.test.ts | 190 +++++ vscode/src/forge/gitea/provider.ts | 356 +++++++++ .../src/{gitea => forge}/issueComment.test.ts | 0 vscode/src/{gitea => forge}/issueComment.ts | 0 vscode/src/forge/issueLoader.test.ts | 162 ++++ vscode/src/forge/issueLoader.ts | 234 ++++++ vscode/src/{gitea => forge}/markers.test.ts | 0 vscode/src/{gitea => forge}/markers.ts | 0 vscode/src/forge/state.ts | 39 + vscode/src/forge/types.ts | 169 ++++ vscode/src/git/remote.ts | 2 +- vscode/src/gitea/api.comments.test.ts | 29 - vscode/src/gitea/api.ts | 563 ------------- vscode/src/gitea/issueLoader.test.ts | 170 ---- vscode/src/gitea/issueLoader.ts | 351 -------- vscode/src/gitea/repoContext.ts | 30 - vscode/src/gitea/stateJson.ts | 49 -- vscode/src/issues/localState.ts | 2 +- vscode/src/issues/stateFields.ts | 2 +- vscode/src/issues/stateRouter.ts | 47 +- vscode/src/{gitea => issues}/types.ts | 0 vscode/src/panel/KanbanPanel.cache.test.ts | 5 +- vscode/src/panel/KanbanPanel.ts | 186 ++--- vscode/src/panel/handlers/branchCleanup.ts | 13 +- vscode/src/panel/handlers/forgeGate.ts | 23 + vscode/src/panel/handlers/handoff.ts | 2 +- vscode/src/panel/handlers/handoffFlow.ts | 61 +- vscode/src/panel/handlers/issueChat.ts | 32 +- vscode/src/panel/handlers/issues.ts | 749 +++--------------- vscode/src/panel/handlers/prFiles.ts | 116 ++- vscode/src/panel/handlers/sessionHandoff.ts | 3 +- vscode/src/panel/handlers/sessions.ts | 97 +-- vscode/src/panel/handlers/terminals.ts | 2 +- vscode/src/panel/handlers/todoReset.ts | 2 +- vscode/src/panel/handlers/worktree.ts | 22 +- vscode/src/panel/handlers/youtrackIssues.ts | 2 +- vscode/src/panel/issueLock.test.ts | 2 +- vscode/src/panel/issueLock.ts | 2 +- vscode/src/panel/kanbanSnapshot.ts | 2 +- vscode/src/panel/kanbanState.test.ts | 2 +- vscode/src/panel/messages.ts | 2 +- vscode/src/sessions/sessionFiles.test.ts | 2 +- vscode/src/sessions/sessionFiles.ts | 2 +- vscode/src/shared/kanbanUpdates.test.ts | 2 +- vscode/src/shared/kanbanUpdates.ts | 2 +- vscode/src/webhook/coordinator.cache.test.ts | 17 +- .../webhook/coordinator.issueCreation.test.ts | 23 +- vscode/src/webhook/coordinator.ts | 326 ++------ vscode/src/youtrack/api.ts | 4 +- vscode/src/youtrack/issueLoader.ts | 6 +- .../{gitea => forge}/issueComment.test.ts | 2 +- .../stateJson.test.ts => forge/state.test.ts} | 101 +-- 64 files changed, 1881 insertions(+), 2718 deletions(-) rename vscode/src/{gitea => forge}/avatars.ts (55%) create mode 100644 vscode/src/forge/context.test.ts create mode 100644 vscode/src/forge/context.ts create mode 100644 vscode/src/forge/gitea/provider.test.ts create mode 100644 vscode/src/forge/gitea/provider.ts rename vscode/src/{gitea => forge}/issueComment.test.ts (100%) rename vscode/src/{gitea => forge}/issueComment.ts (100%) create mode 100644 vscode/src/forge/issueLoader.test.ts create mode 100644 vscode/src/forge/issueLoader.ts rename vscode/src/{gitea => forge}/markers.test.ts (100%) rename vscode/src/{gitea => forge}/markers.ts (100%) create mode 100644 vscode/src/forge/state.ts create mode 100644 vscode/src/forge/types.ts delete mode 100644 vscode/src/gitea/api.comments.test.ts delete mode 100644 vscode/src/gitea/api.ts delete mode 100644 vscode/src/gitea/issueLoader.test.ts delete mode 100644 vscode/src/gitea/issueLoader.ts delete mode 100644 vscode/src/gitea/repoContext.ts delete mode 100644 vscode/src/gitea/stateJson.ts rename vscode/src/{gitea => issues}/types.ts (100%) create mode 100644 vscode/src/panel/handlers/forgeGate.ts rename vscode/test/{gitea => forge}/issueComment.test.ts (99%) rename vscode/test/{gitea/stateJson.test.ts => forge/state.test.ts} (79%) diff --git a/vscode/src/auth/identity.ts b/vscode/src/auth/identity.ts index 7987f1c..7dea73a 100644 --- a/vscode/src/auth/identity.ts +++ b/vscode/src/auth/identity.ts @@ -1,35 +1,33 @@ /** - * 会话身份:当前 Gitea token 对应的用户,按 host 缓存。 + * 会话身份:当前凭据在代码托管仓库上对应的用户,按 host 缓存。 * - * 多人协作的三个消费方: - * - webhook 副作用门禁(coordinator)用 login 判断"这是不是我的工单" - * - 实施认领(handleImplement)把 assignee 设为当前用户 - * - 状态栏常驻展示身份,token 失效/用错同事 token 时第一眼就能看出来 + * 多人协作的三个消费方: + * - webhook 副作用门禁(coordinator)用 login 判断「这是不是我的工单」 + * - 实施认领(handleImplement)把 assignee 设为当前用户 + * - 状态栏常驻展示身份,凭据失效或用错同事的 token 时第一眼就能看出来 */ import type { ExtensionContext, StatusBarItem } from 'vscode' -import type { GiteaUser } from '../gitea/api' +import type { ForgeContext } from '../forge/context' +import type { ForgeUser } from '../forge/types' import { spawnSync } from 'node:child_process' -import { StatusBarAlignment, ThemeColor, window, workspace } from 'vscode' -import { detectRepo } from '../git/remote' -import { getCurrentUser } from '../gitea/api' +import { StatusBarAlignment, ThemeColor, window } from 'vscode' +import { resolveForge } from '../forge/context' import { logger } from '../logging/logger' -import { getToken } from './secrets' -const userByHost = new Map() +const userByHost = new Map() let statusBar: StatusBarItem | undefined /** - * 解析并缓存 token 对应的 login。失败(网络/token 失效)返回 undefined, - * 由调用方决定降级行为——门禁侧的语义是"身份未知 → observe"。 + * 解析并缓存 `forge` 当前凭据对应的 login。失败(网络、凭据失效)返回 `undefined`,由调用方决定降级:门禁侧的语义是「身份未知 → 只观察」。 */ -export async function loginForToken(host: string, token: string): Promise { - const hit = userByHost.get(host) +export async function loginFor(forge: ForgeContext): Promise { + const hit = userByHost.get(forge.host) if (hit) return hit.login try { - const user = await getCurrentUser({ host, token }) - userByHost.set(host, user) + const user = await forge.provider.getCurrentUser() + userByHost.set(forge.host, user) return user.login } catch (err) { @@ -37,14 +35,14 @@ export async function loginForToken(host: string, token: string): Promise { if (!statusBar) { @@ -63,31 +60,25 @@ export async function initIdentity(ctx: ExtensionContext): Promise { ctx.subscriptions.push(statusBar) } - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) { - statusBar.hide() - return - } - const remote = await detectRepo(ws) - if (!remote) { - statusBar.hide() - return - } - - const token = await getToken(ctx, remote.host) - if (!token) { + const resolved = await resolveForge(ctx) + if (!resolved.ok) { + if (resolved.reason !== 'no-credentials') { + statusBar.hide() + return + } statusBar.text = '$(account) spx: 未配置 token' - statusBar.tooltip = `尚未为 ${remote.host} 配置 Gitea token,点击配置` + statusBar.tooltip = `尚未为 ${resolved.host} 配置访问令牌,点击配置` statusBar.command = 'superpowers.setGiteaToken' statusBar.backgroundColor = new ThemeColor('statusBarItem.warningBackground') statusBar.show() return } - const login = await loginForToken(remote.host, token) + const { forge } = resolved + const login = await loginFor(forge) if (!login) { statusBar.text = '$(account) spx: token 失效' - statusBar.tooltip = `${remote.host} 的 token 无法解析身份,点击重新配置` + statusBar.tooltip = `${forge.host} 的 token 无法解析身份,点击重新配置` statusBar.command = 'superpowers.setGiteaToken' statusBar.backgroundColor = new ThemeColor('statusBarItem.errorBackground') statusBar.show() @@ -95,37 +86,36 @@ export async function initIdentity(ctx: ExtensionContext): Promise { } statusBar.text = `$(account) spx: ${login}` - statusBar.tooltip = `Gitea 身份 ${login} @ ${remote.host}` + statusBar.tooltip = `身份 ${login} @ ${forge.host}` statusBar.command = 'superpowers.setGiteaToken' statusBar.backgroundColor = undefined statusBar.show() logger.add({ level: 'info', source: 'identity', - message: `当前 Gitea 身份 ${login} @ ${remote.host}`, + message: `当前身份 ${login} @ ${forge.host}`, }) - checkGitIdentityMatch(ws, remote.host, login) + checkGitIdentityMatch(forge.workspaceRoot, forge.host, login) } /** - * Gitea 账号邮箱 vs `git config user.email`:不一致基本等于"用了别人的 - * token"或"git 身份没配"。只警告不拦截——同名多邮箱的合法场景存在。 + * 代码托管账号邮箱对不上 `git config user.email`,基本等于用了别人的 token 或 git 身份没配;只警告不拦截,同名多邮箱的合法场景存在。 */ function checkGitIdentityMatch(workspaceRoot: string, host: string, login: string): void { - const giteaEmail = userByHost.get(host)?.email?.trim().toLowerCase() - if (!giteaEmail) + const forgeEmail = userByHost.get(host)?.email?.trim().toLowerCase() + if (!forgeEmail) return const res = spawnSync('git', ['-C', workspaceRoot, 'config', 'user.email'], { encoding: 'utf-8' }) const gitEmail = (res.stdout ?? '').trim().toLowerCase() - if (!gitEmail || gitEmail === giteaEmail) + if (!gitEmail || gitEmail === forgeEmail) return logger.add({ level: 'warn', source: 'identity', - message: `Gitea 身份与 git 提交邮箱不一致: ${login}(${giteaEmail}) vs git(${gitEmail})`, + message: `代码托管身份与 git 提交邮箱不一致: ${login}(${forgeEmail}) vs git(${gitEmail})`, }) void window.showWarningMessage( - `Gitea token 身份是 ${login}(${giteaEmail}),但 git 提交邮箱是 ${gitEmail}。确认没有用错同事的 token。`, + `token 身份是 ${login}(${forgeEmail}),但 git 提交邮箱是 ${gitEmail}。确认没有用错同事的 token。`, ) } diff --git a/vscode/src/codeReader/review/changedFiles.test.ts b/vscode/src/codeReader/review/changedFiles.test.ts index ee523e8..7114a35 100644 --- a/vscode/src/codeReader/review/changedFiles.test.ts +++ b/vscode/src/codeReader/review/changedFiles.test.ts @@ -1,11 +1,12 @@ +import type { PrFileStatus } from '../../forge/types' import { describe, expect, it } from 'vitest' import { CHANGE_STYLES, changeOf, reviewBaseFile, reviewTree } from './changedFiles' describe('改动状态', () => { - it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => { + it('新增 A、删除 D,别的(改名、复制、修改)都算修改 M', () => { expect(changeOf('added')).toBe('A') expect(changeOf('deleted')).toBe('D') - for (const status of ['changed', 'modified', 'renamed', 'copied', 'unchanged', '']) + for (const status of ['modified', 'renamed', 'copied'] as const) expect(changeOf(status)).toBe('M') }) @@ -18,10 +19,10 @@ describe('改动状态', () => { }) describe('审查时在 PR 目标分支上取哪个文件', () => { - const files = [ + const files: { path: string, status: PrFileStatus, previousFilename?: string }[] = [ { path: 'src/new.ts', status: 'added' }, { path: 'src/moved.ts', status: 'renamed', previousFilename: 'src/old.ts' }, - { path: 'src/changed.ts', status: 'changed' }, + { path: 'src/changed.ts', status: 'modified' }, ] it('新加的目标分支上没有,改名的取改名前的路径,别的照原路径', () => { @@ -33,13 +34,13 @@ describe('审查时在 PR 目标分支上取哪个文件', () => { }) describe('审查时树上摆哪些文件', () => { - const files = [ - { path: 'backend/app.py', status: 'changed' }, + const files: { path: string, status: PrFileStatus }[] = [ + { path: 'backend/app.py', status: 'modified' }, { path: 'backend/test_app.py', status: 'added' }, { path: 'backend/tests/test_avatar.py', status: 'added' }, { path: 'backend/tests/fixtures/avatar.png', status: 'added' }, - { path: 'web/src/__tests__/Avatar.tsx', status: 'changed' }, - { path: 'web/src/Avatar.tsx', status: 'changed' }, + { path: 'web/src/__tests__/Avatar.tsx', status: 'modified' }, + { path: 'web/src/Avatar.tsx', status: 'modified' }, ] const names = (tree: ReturnType['tree'], dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name) @@ -62,7 +63,7 @@ describe('审查时树上摆哪些文件', () => { expect(names(tree, 'web/src')).toEqual(['__tests__', 'Avatar.tsx']) }) - it('改动状态照 Gitea 的', () => { + it('改动状态照 PR 文件的', () => { const { tree } = reviewTree(files, true) expect(tree.get('backend')?.[0]).toMatchObject({ path: 'backend/app.py', change: 'M' }) }) diff --git a/vscode/src/codeReader/review/changedFiles.ts b/vscode/src/codeReader/review/changedFiles.ts index c73db35..4d6f1a6 100644 --- a/vscode/src/codeReader/review/changedFiles.ts +++ b/vscode/src/codeReader/review/changedFiles.ts @@ -1,12 +1,10 @@ +import type { PrFileStatus } from '../../forge/types' import type { ChangedTree, FileChange } from '../explorer/changedTree' import { buildChangedTree } from '../explorer/changedTree' import { isTestFile } from '../uncommitted/testFiles' -/** - * Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged: - * 新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。 - */ -export function changeOf(status: string): FileChange { +/** PR 文件的改动类型 → M / A / D:新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。 */ +export function changeOf(status: PrFileStatus): FileChange { return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M' } @@ -14,7 +12,7 @@ export function changeOf(status: string): FileChange { * 审查时树上摆的:PR 改过的文件和通往它们的目录。隐藏测试文件时先剔掉测试文件再摆,只装着测试文件的目录跟着不摆; * hidden 是剔了几个。 */ -export function reviewTree(files: readonly { path: string, status: string }[], hideTests: boolean): { tree: ChangedTree, hidden: number } { +export function reviewTree(files: readonly { path: string, status: PrFileStatus }[], hideTests: boolean): { tree: ChangedTree, hidden: number } { const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) })) const shown = hideTests ? changes.filter(file => !isTestFile(file.path)) : changes return { tree: buildChangedTree(shown), hidden: changes.length - shown.length } @@ -22,9 +20,9 @@ export function reviewTree(files: readonly { path: string, status: string }[], h /** * 审查时工作副本里的一个文件(path 相对工作副本的根)在 PR 目标分支上叫什么、有没有: - * 改名的取改名前的路径,PR 里新加的(Gitea 的 added)目标分支上没有。PR 没改过的文件照原路径取。 + * 改名的取改名前的路径,PR 里新加的目标分支上没有。PR 没改过的文件照原路径取。 */ -export function reviewBaseFile(files: readonly { path: string, status: string, previousFilename?: string }[], path: string): { path: string, added: boolean } { +export function reviewBaseFile(files: readonly { path: string, status: PrFileStatus, previousFilename?: string }[], path: string): { path: string, added: boolean } { const file = files.find(item => item.path === path) return { path: file?.previousFilename ?? path, added: file?.status === 'added' } } diff --git a/vscode/src/codeReader/review/pickList.test.ts b/vscode/src/codeReader/review/pickList.test.ts index 5bacf07..49617c6 100644 --- a/vscode/src/codeReader/review/pickList.test.ts +++ b/vscode/src/codeReader/review/pickList.test.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../../gitea/types' +import type { Issue } from '../../issues/types' import { describe, expect, it } from 'vitest' import { boardIssues } from '../../panel/kanbanSnapshot' import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' diff --git a/vscode/src/codeReader/review/pickList.ts b/vscode/src/codeReader/review/pickList.ts index 934327e..046c16e 100644 --- a/vscode/src/codeReader/review/pickList.ts +++ b/vscode/src/codeReader/review/pickList.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../../gitea/types' +import type { Issue } from '../../issues/types' import { columnLabel, prNumberOf } from './reviewRules' /** PR 改了几个文件:还在读、没读到、读到了。 */ diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts index a64df3f..c2ee460 100644 --- a/vscode/src/codeReader/review/reviewMode.ts +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -1,12 +1,13 @@ import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode' -import type { PrFileDto } from '../../gitea/api' -import type { RepoContext } from '../../gitea/repoContext' -import type { Issue } from '../../gitea/types' +import type { ForgeContext } from '../../forge/context' +import type { ForgePrFile } from '../../forge/types' +import type { Issue } from '../../issues/types' import type { DiffBase, DiffBaseSource } from '../diff/diffBase' import type { FileTreeProvider, TreeNode } from '../explorer/fileTree' import type { FileCount } from './pickList' import * as path from 'node:path' import { commands, EventEmitter, QuickPickItemKind, Uri, window, workspace } from 'vscode' +import { requireForge } from '../../forge/context' import { fileAtRef } from '../../git/fileAt' import { logger } from '../../logging/logger' import { WORKSPACE_TREE } from '../explorer/fileTree' @@ -17,7 +18,7 @@ import { ChangeDecorations } from './changeDecorations' import { changeOf, reviewBaseFile, reviewTree } from './changedFiles' import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList' import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewDescription, reviewMessage } from './reviewRules' -import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource' +import { loadIssue, loadPullRequest, reviewCandidateIssues } from './reviewSource' import { localWorktree, prepareWorktree } from './reviewWorktree' /** workspaceState:正在审查的工单号,重开窗口接着审查。 */ @@ -35,12 +36,12 @@ type How = 'pick' | 'reload' | 'restore' /** * 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本, - * 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(Gitea 没给时退回目标分支的提交,同看板「改动」tab)。 + * 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(取不到时退回目标分支的提交,同看板「改动」tab)。 */ interface Loaded { issue: Issue pr: number - files: PrFileDto[] + files: ForgePrFile[] worktree: string base: { ref: string, commit: string } } @@ -115,12 +116,12 @@ export class ReviewMode implements DiffBaseSource, Disposable { }) picker.show() - let repo: RepoContext + let repo: ForgeContext try { - repo = await requireRepo(this.context) + repo = await requireForge(this.context) } catch (err) { - logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的 Gitea 仓库', details: errorText(err) }) + logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的代码托管仓库', details: errorText(err) }) picker.hide() void window.showErrorMessage(`没法选工单:${errorText(err)}`) return @@ -228,9 +229,9 @@ export class ReviewMode implements DiffBaseSource, Disposable { /** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */ /** 读一张工单的 PR 改了几个文件;没读到记进日志,这一项写读不到。 */ - private async countFiles(repo: RepoContext, issueNumber: number, pr: number): Promise { + private async countFiles(repo: ForgeContext, issueNumber: number, pr: number): Promise { try { - return { count: (await loadPullRequestFiles(repo, pr)).length } + return { count: (await repo.provider.listPullRequestFiles(pr)).length } } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) }) @@ -281,7 +282,7 @@ export class ReviewMode implements DiffBaseSource, Disposable { } private async load(issueNumber: number, step: (text: string) => void): Promise { - const repo = await requireRepo(this.context) + const repo = await requireForge(this.context) step(`正在读取 #${issueNumber}…`) const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber)) if ('blocked' in reviewable) @@ -291,10 +292,11 @@ export class ReviewMode implements DiffBaseSource, Disposable { const reviewablePull = reviewablePullRequest(pr, await loadPullRequest(repo, pr)) if ('blocked' in reviewablePull) return reviewablePull - const files = await loadPullRequestFiles(repo, pr) - const { headSha, baseRef, mergeBase, baseSha } = reviewablePull.pull + const files = await repo.provider.listPullRequestFiles(pr) + const { headSha, baseRef } = reviewablePull.pull + const baseCommit = await repo.provider.pullRequestDiffBase(reviewablePull.pull) const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha }, step) - return { issue, pr, files, worktree, base: { ref: baseRef, commit: mergeBase || baseSha } } + return { issue, pr, files, worktree, base: { ref: baseRef, commit: baseCommit } } } private async show(loaded: Loaded): Promise { diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts index 8cec88d..50e0cca 100644 --- a/vscode/src/codeReader/review/reviewRules.ts +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueColumn } from '../../gitea/types' +import type { Issue, IssueColumn } from '../../issues/types' import { withHiddenTests } from '../uncommitted/testFiles' /** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */ diff --git a/vscode/src/codeReader/review/reviewSource.ts b/vscode/src/codeReader/review/reviewSource.ts index 493364e..18af2ad 100644 --- a/vscode/src/codeReader/review/reviewSource.ts +++ b/vscode/src/codeReader/review/reviewSource.ts @@ -1,28 +1,19 @@ /** * 审查工单要的看板数据,全走看板自己的那套:列工单用看板上现在摆着的,重读一张工单用看板的单张加载 - * (state JSON 定列),本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。 + * (状态评论定列),本机记录(worktree 路径)照看板叠上,PR 用代码托管仓库的接口。 */ import type { ExtensionContext } from 'vscode' -import type { GiteaPullRequest, PrFileDto } from '../../gitea/api' -import type { RepoContext } from '../../gitea/repoContext' -import type { Issue } from '../../gitea/types' +import type { ForgeContext } from '../../forge/context' +import type { ForgePullRequest } from '../../forge/types' +import type { Issue } from '../../issues/types' import type { BoardIssues } from '../../panel/kanbanSnapshot' -import { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api' -import { loadSingleIssue } from '../../gitea/issueLoader' -import { resolveRepoContext } from '../../gitea/repoContext' +import { loadSingleIssue } from '../../forge/issueLoader' +import { ForgeApiError } from '../../forge/types' import { overlayLocalIssueState } from '../../issues/localState' import { KanbanWebviewPanel } from '../../panel/KanbanPanel' import { reviewCandidates } from './reviewRules' -/** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */ -export async function requireRepo(context: ExtensionContext): Promise { - const repo = await resolveRepoContext(context) - if (!repo) - throw new Error('当前工作区没有 Gitea 仓库或未配置 token') - return repo -} - /** * 选工单弹框列的工单(哪些列、哪些工单见 reviewCandidates):取看板上现在摆着的(用户看到的就是它),不再加载一遍; * 看板还没加载过时 undefined。进审查时再单独重读选中的那张(loadIssue)。 @@ -33,24 +24,19 @@ export function reviewCandidateIssues(context: ExtensionContext): BoardIssues | } /** 一张工单的最新状态;工单已经不存在时 null。 */ -export async function loadIssue(context: ExtensionContext, repo: RepoContext, issueNumber: number): Promise { - const issue = await loadSingleIssue({ ...repo, issueNumber }) - return issue && overlayLocalIssueState(context, [issue], repo.workspaceRoot)[0] +export async function loadIssue(context: ExtensionContext, forge: ForgeContext, issueNumber: number): Promise { + const issue = await loadSingleIssue(forge, issueNumber) + return issue && overlayLocalIssueState(context, [issue], forge.workspaceRoot)[0] } /** PR 的现状;PR 已经不存在时 null。 */ -export async function loadPullRequest(repo: RepoContext, pr: number): Promise { +export async function loadPullRequest(forge: ForgeContext, pr: number): Promise { try { - return await getPullRequest({ ...repo, index: pr }) + return await forge.provider.getPullRequest(pr) } catch (err) { - if (err instanceof GiteaApiError && err.status === 404) + if (err instanceof ForgeApiError && err.status === 404) return null throw err } } - -/** PR 改过的全部文件。 */ -export function loadPullRequestFiles(repo: RepoContext, pr: number): Promise { - return listPullRequestFiles({ ...repo, index: pr }) -} diff --git a/vscode/src/codeReader/review/reviewWorktree.ts b/vscode/src/codeReader/review/reviewWorktree.ts index 9703a66..f19792c 100644 --- a/vscode/src/codeReader/review/reviewWorktree.ts +++ b/vscode/src/codeReader/review/reviewWorktree.ts @@ -1,6 +1,6 @@ import type { ExtensionContext } from 'vscode' -import type { RepoContext } from '../../gitea/repoContext' -import type { Issue } from '../../gitea/types' +import type { ForgeContext } from '../../forge/context' +import type { Issue } from '../../issues/types' import { existsSync } from 'node:fs' import { window } from 'vscode' import { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead, resolveWorktreePath } from '../../git/worktree' @@ -34,7 +34,7 @@ export function localWorktree(context: ExtensionContext, workspaceRoot: string, */ export async function prepareWorktree( context: ExtensionContext, - repo: RepoContext, + repo: ForgeContext, issue: Pick, pull: { number: number, headSha: string }, step: (text: string) => void, diff --git a/vscode/src/gitea/avatars.ts b/vscode/src/forge/avatars.ts similarity index 55% rename from vscode/src/gitea/avatars.ts rename to vscode/src/forge/avatars.ts index 85f5759..6596cd0 100644 --- a/vscode/src/gitea/avatars.ts +++ b/vscode/src/forge/avatars.ts @@ -1,23 +1,22 @@ /** - * 头像取回:webview 的 CSP 只放行扩展自身资源与 `data:`,直接把 Gitea 的 - * avatar_url 塞进 会被拦掉(私有实例的头像还可能要带 token)。所以 - * 统一由扩展侧抓回来转成 data URI 再下发。 + * 头像取回:webview 的 CSP 只放行扩展自身资源与 `data:`,直接把头像地址塞进 会被拦掉(私有实例的头像还可能要带凭据), + * 所以统一由扩展侧抓回来转成 data URI 再下发。 * - * 按 URL 进程内缓存:同一批工单里重复出现的同事只下载一次,刷新看板也复用。 - * 任何失败都缓存成「无头像」,前端回退到首字母圆点,不重试拖慢加载。 + * 按地址进程内缓存:同一批工单里重复出现的同事只下载一次,刷新看板也复用。任何失败都缓存成「无头像」,前端回退到首字母圆点,不重试拖慢加载。 */ +import type { ForgeProvider } from './types' import { Buffer } from 'node:buffer' -/** 单张头像的体积上限;Gitea 头像通常 <20KB,超限的直接当无头像。 */ +/** 单张头像的体积上限;头像通常 <20KB,超限的直接当无头像。 */ const MAX_BYTES = 256 * 1024 const cache = new Map() -/** 抓单张头像并转 data URI;失败或超限返回 undefined。 */ -async function fetchAvatar(url: string, token: string): Promise { +/** 抓一张图转成 data URI;失败、超限或不是图片都返回 `undefined`。 */ +export async function fetchImageDataUri(url: string, headers: Record): Promise { try { - const res = await fetch(url, { headers: { Authorization: `token ${token}` } }) + const res = await fetch(url, { headers }) if (!res.ok) return undefined const buf = await res.arrayBuffer() @@ -34,12 +33,11 @@ async function fetchAvatar(url: string, token: string): Promise avatar_url` 解析成 `login -> data URI`。没有 URL、抓取失败 - * 的 login 不出现在结果里。 + * 把 `login -> 头像地址` 解析成 `login -> data URI`,具体怎么抓由 `provider` 决定。没有地址、抓取失败的 login 不出现在结果里。 */ export async function resolveAvatars( + provider: ForgeProvider, entries: Iterable, - token: string, ): Promise> { const wanted = new Map() for (const [login, url] of entries) { @@ -49,7 +47,7 @@ export async function resolveAvatars( const pairs = await Promise.all([...wanted].map(async ([login, url]) => { if (!cache.has(url)) - cache.set(url, await fetchAvatar(url, token)) + cache.set(url, await provider.avatarDataUri(url)) const dataUri = cache.get(url) return dataUri ? [login, dataUri] as const : undefined })) diff --git a/vscode/src/forge/context.test.ts b/vscode/src/forge/context.test.ts new file mode 100644 index 0000000..a912ec6 --- /dev/null +++ b/vscode/src/forge/context.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + folders: [{ uri: { fsPath: '/repo' } }] as Array<{ uri: { fsPath: string } }> | undefined, + detectRepo: vi.fn(), + getToken: vi.fn(), +})) + +vi.mock('vscode', () => ({ + workspace: { + get workspaceFolders() { + return mocks.folders + }, + }, +})) +vi.mock('../git/remote', () => ({ detectRepo: mocks.detectRepo })) +vi.mock('../auth/secrets', () => ({ getToken: mocks.getToken })) + +const ctx = {} as never + +describe('forgeKindForHost', () => { + it('只有 github.com(不分大小写)是 GitHub,其余都是 Gitea', async () => { + const { forgeKindForHost } = await import('./context.js') + expect(forgeKindForHost('github.com')).toBe('github') + expect(forgeKindForHost('GitHub.com')).toBe('github') + expect(forgeKindForHost('github.example.com')).toBe('gitea') + expect(forgeKindForHost('gitea.x.cn')).toBe('gitea') + }) +}) + +describe('resolveForge', () => { + beforeEach(() => { + mocks.folders = [{ uri: { fsPath: '/repo' } }] + mocks.detectRepo.mockReset() + mocks.getToken.mockReset() + }) + + it('gitea 仓库带上 token 造出 provider', async () => { + const { resolveForge } = await import('./context.js') + mocks.detectRepo.mockResolvedValue({ host: 'gitea.x.cn', owner: 'o', repo: 'r' }) + mocks.getToken.mockResolvedValue('t') + + const resolved = await resolveForge(ctx) + + expect(resolved.ok).toBe(true) + if (resolved.ok) { + expect(resolved.forge).toMatchObject({ workspaceRoot: '/repo', kind: 'gitea', host: 'gitea.x.cn', owner: 'o', repo: 'r' }) + expect(resolved.forge.provider.kind).toBe('gitea') + } + expect(mocks.getToken).toHaveBeenCalledWith(ctx, 'gitea.x.cn') + }) + + it('gitHub 仓库暂未支持,不去取 token', async () => { + const { resolveForge } = await import('./context.js') + mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' }) + + expect(await resolveForge(ctx)).toEqual({ ok: false, reason: 'unsupported', kind: 'github', host: 'github.com', message: '暂未支持 GitHub 仓库' }) + expect(mocks.getToken).not.toHaveBeenCalled() + }) + + it('各种失败的提示都不带 Gitea 字样', async () => { + const { resolveForge } = await import('./context.js') + + mocks.detectRepo.mockResolvedValue({ host: 'git.example.com', owner: 'o', repo: 'r' }) + mocks.getToken.mockResolvedValue(undefined) + const noToken = await resolveForge(ctx) + expect(noToken).toMatchObject({ ok: false, reason: 'no-credentials', host: 'git.example.com', message: '请先配置 git.example.com 的访问令牌' }) + + mocks.detectRepo.mockResolvedValue(undefined) + const noRemote = await resolveForge(ctx) + expect(noRemote).toMatchObject({ ok: false, reason: 'no-remote' }) + + mocks.folders = undefined + const noWorkspace = await resolveForge(ctx) + expect(noWorkspace).toMatchObject({ ok: false, reason: 'no-workspace' }) + + for (const r of [noToken, noRemote, noWorkspace]) + expect(r.ok ? '' : r.message).not.toMatch(/gitea/i) + }) + + it('requireForge 解析失败时把原因抛出来', async () => { + const { requireForge } = await import('./context.js') + mocks.detectRepo.mockResolvedValue(undefined) + await expect(requireForge(ctx)).rejects.toThrow('当前工作区没有能识别的 origin 远程仓库') + }) +}) diff --git a/vscode/src/forge/context.ts b/vscode/src/forge/context.ts new file mode 100644 index 0000000..4c46eb5 --- /dev/null +++ b/vscode/src/forge/context.ts @@ -0,0 +1,81 @@ +/** + * 当前工作区对应哪个代码托管仓库:读 git 的 origin 地址,按 host 认出是哪一家,带上凭据造出 [ForgeProvider]。 + * + * 所有要访问仓库的地方都从这里拿 [ForgeContext],不再各自去读 remote 和 token。 + */ + +import type { ExtensionContext } from 'vscode' +import type { Remote } from '../git/remote' +import type { ForgeKind, ForgeProvider } from './types' +import { workspace } from 'vscode' +import { getToken } from '../auth/secrets' +import { detectRepo } from '../git/remote' +import { GiteaProvider } from './gitea/provider' + +/** 一个工作区对应的代码托管仓库,和访问它用的 [ForgeProvider]。 */ +export interface ForgeContext { + workspaceRoot: string + kind: ForgeKind + host: string + owner: string + repo: string + provider: ForgeProvider +} + +/** + * 解析结果;失败时 `message` 是可以直接给人看的原因,`reason` 给需要分情况处理的调用方: + * `no-workspace` 没打开工作区,`no-remote` 没有能认出的 origin,`no-credentials` 没有凭据,`unsupported` 这一家还不支持。 + */ +export type ForgeResolution + = | { ok: true, forge: ForgeContext } + | { ok: false, reason: 'no-workspace' | 'no-remote', message: string } + | { ok: false, reason: 'no-credentials' | 'unsupported', message: string, kind: ForgeKind, host: string } + +/** host 小写后是 `github.com` 的是 GitHub,其余都当 Gitea;不认 GitHub Enterprise。 */ +export function forgeKindForHost(host: string): ForgeKind { + return host.toLowerCase() === 'github.com' ? 'github' : 'gitea' +} + +/** 给指定的仓库造 [ForgeContext];webhook 记下的仓库不一定是当前工作区的 origin,所以单独开放。 */ +export async function openForge(ctx: ExtensionContext, workspaceRoot: string, remote: Remote): Promise { + const kind = forgeKindForHost(remote.host) + if (kind === 'github') + return { ok: false, reason: 'unsupported', kind, host: remote.host, message: '暂未支持 GitHub 仓库' } + const token = await getToken(ctx, remote.host) + if (!token) + return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: `请先配置 ${remote.host} 的访问令牌` } + return { + ok: true, + forge: { + workspaceRoot, + kind, + host: remote.host, + owner: remote.owner, + repo: remote.repo, + provider: new GiteaProvider({ ...remote, token }), + }, + } +} + +/** 解析当前工作区(第一个工作区文件夹)的代码托管仓库。 */ +export async function resolveForge(ctx: ExtensionContext): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) + return { ok: false, reason: 'no-workspace', message: '请先打开一个工作区文件夹' } + const remote = await detectRepo(workspaceRoot) + if (!remote) + return { ok: false, reason: 'no-remote', message: '当前工作区没有能识别的 origin 远程仓库' } + return openForge(ctx, workspaceRoot, remote) +} + +/** + * 同 [resolveForge],解析不出来时抛错。 + * + * @throws [Error] 解析失败,`message` 是给人看的原因 + */ +export async function requireForge(ctx: ExtensionContext): Promise { + const resolved = await resolveForge(ctx) + if (!resolved.ok) + throw new Error(resolved.message) + return resolved.forge +} diff --git a/vscode/src/forge/gitea/provider.test.ts b/vscode/src/forge/gitea/provider.test.ts new file mode 100644 index 0000000..abd35e5 --- /dev/null +++ b/vscode/src/forge/gitea/provider.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GiteaProvider } from './provider' + +afterEach(() => vi.unstubAllGlobals()) + +const BASE = 'https://gitea.example/api/v1/repos/owner/repo' + +function provider(): GiteaProvider { + return new GiteaProvider({ host: 'gitea.example', owner: 'owner', repo: 'repo', token: 'test-token' }) +} + +function json(data: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(data), init) +} + +/** Gitea 有下一页时在 Link 头里给 rel="next"。 */ +function nextLink(url: string): ResponseInit { + return { headers: { Link: `<${url}>; rel="next", <${url}>; rel="last"` } } +} + +function rawIssue(n: number): Record { + return { + number: n, + title: `工单 ${n}`, + state: 'open', + comments: 2, + html_url: `https://gitea.example/owner/repo/issues/${n}`, + closed_at: null, + user: { login: 'alice', avatar_url: 'https://gitea.example/avatars/a' }, + assignees: [{ login: 'bob' }], + } +} + +function stubFetch(...responses: Response[]): ReturnType { + const fetch = vi.fn() + for (const res of responses) + fetch.mockResolvedValueOnce(res) + vi.stubGlobal('fetch', fetch) + return fetch +} + +function calledUrl(fetch: ReturnType, i: number): URL { + return new URL(fetch.mock.calls[i][0] as string) +} + +describe('列工单', () => { + it('跟着 Link 的 rel="next" 翻页,服务端每页上限比 50 小也不漏', async () => { + const page1 = Array.from({ length: 30 }, (_, i) => rawIssue(i + 1)) + const page2 = [rawIssue(31)] + const fetch = stubFetch(json(page1, nextLink(`${BASE}/issues?page=2`)), json(page2)) + + const issues = await provider().listIssues() + + expect(issues).toHaveLength(31) + expect(fetch).toHaveBeenCalledTimes(2) + expect(calledUrl(fetch, 1).searchParams.get('page')).toBe('2') + }) + + it('没有 rel="next" 就停,哪怕这一页正好满 50 条', async () => { + const fetch = stubFetch(json(Array.from({ length: 50 }, (_, i) => rawIssue(i + 1)))) + expect(await provider().listIssues()).toHaveLength(50) + expect(fetch).toHaveBeenCalledOnce() + }) + + it('按人过滤换成 Gitea 的 assigned_by / created_by,转成统一的工单', async () => { + const fetch = stubFetch(json([rawIssue(7)]), json([])) + const p = provider() + + expect(await p.listIssues({ assignee: 'bob' })).toEqual([{ + number: 7, + title: '工单 7', + state: 'open', + htmlUrl: 'https://gitea.example/owner/repo/issues/7', + commentCount: 2, + author: { login: 'alice', avatarUrl: 'https://gitea.example/avatars/a' }, + assignees: [{ login: 'bob' }], + }]) + await p.listIssues({ creator: 'alice' }) + + const first = calledUrl(fetch, 0) + expect(first.pathname).toBe('/api/v1/repos/owner/repo/issues') + expect(first.searchParams.get('type')).toBe('issues') + expect(first.searchParams.get('state')).toBe('all') + expect(first.searchParams.get('assigned_by')).toBe('bob') + expect(calledUrl(fetch, 1).searchParams.get('created_by')).toBe('alice') + }) +}) + +describe('单个工单的评论', () => { + it.each([0, 50, 78])('返回 %i 条评论时只发一次请求,不传分页参数', async (count) => { + const comments = Array.from({ length: count }, (_, i) => ({ id: i + 1, body: `评论 ${i}`, created_at: '2020-01-01T00:00:00Z' })) + const fetch = stubFetch(json(comments)) + + const result = await provider().listIssueComments(408) + + expect(result).toHaveLength(count) + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith(`${BASE}/issues/408/comments`, expect.objectContaining({ headers: expect.any(Object) })) + }) +}) + +describe('依赖', () => { + it('翻完所有页,只留工单编号', async () => { + const fetch = stubFetch(json([{ number: 3 }], nextLink(`${BASE}/issues/9/dependencies?page=2`)), json([{ number: 5 }])) + expect(await provider().listDependencies(9)).toEqual([3, 5]) + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) + +describe('pR', () => { + it('合并基从同一次 PR 请求里取,不再发请求', async () => { + const fetch = stubFetch(json({ + number: 4, + state: 'open', + merged: false, + html_url: 'https://gitea.example/owner/repo/pulls/4', + body: 'Closes #2', + head: { sha: 'h1', ref: 'feature/x' }, + base: { sha: 'b1', ref: 'master' }, + merge_base: 'm1', + })) + const p = provider() + + const pr = await p.getPullRequest(4) + + expect(pr).toEqual({ number: 4, state: 'open', merged: false, htmlUrl: 'https://gitea.example/owner/repo/pulls/4', body: 'Closes #2', headSha: 'h1', headRef: 'feature/x', baseSha: 'b1', baseRef: 'master' }) + expect(await p.pullRequestDiffBase(pr)).toBe('m1') + expect(fetch).toHaveBeenCalledOnce() + }) + + it('合并基缺失时退回目标分支提交', async () => { + stubFetch(json({ number: 4, head: { sha: 'h1' }, base: { sha: 'b1' } })) + const p = provider() + expect(await p.pullRequestDiffBase(await p.getPullRequest(4))).toBe('b1') + }) + + it('改动文件的状态统一成 modified / deleted 等,并翻页', async () => { + const fetch = stubFetch( + json([{ filename: 'a.ts', status: 'changed', additions: 1, deletions: 2 }], nextLink(`${BASE}/pulls/4/files?page=2`)), + json([{ filename: 'b.ts', status: 'deleted' }, { filename: 'c.ts', previous_filename: 'old.ts', status: 'renamed' }]), + ) + expect(await provider().listPullRequestFiles(4)).toEqual([ + { path: 'a.ts', status: 'modified', additions: 1, deletions: 2 }, + { path: 'b.ts', status: 'deleted', additions: 0, deletions: 0 }, + { path: 'c.ts', status: 'renamed', additions: 0, deletions: 0, previousFilename: 'old.ts' }, + ]) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it.each([ + [405, 'not mergeable', 'conflict'], + [409, 'There are conflicting files', 'conflict'], + [409, 'merge push out of date', 'head-changed'], + [403, 'forbidden', 'rejected'], + ])('合并被拒(%i %s)→ %s', async (status, message, reason) => { + stubFetch(new Response(message, { status })) + expect(await provider().mergePullRequest(4, 'merge')).toEqual({ merged: false, reason, message }) + }) + + it('合并成功,请求体沿用原来的字段', async () => { + const fetch = stubFetch(new Response(null, { status: 200 })) + expect(await provider().mergePullRequest(4, 'merge')).toEqual({ merged: true }) + expect(fetch).toHaveBeenCalledWith(`${BASE}/pulls/4/merge`, expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ Do: 'merge', delete_branch_after_merge: false, force_merge: false }), + })) + }) +}) + +describe('头像', () => { + it('只对本仓库的 host 带 token', async () => { + const png = () => new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }) + const fetch = stubFetch(png(), png()) + const p = provider() + + expect(await p.avatarDataUri('https://gitea.example/avatars/a')).toMatch(/^data:image\/png;base64,/) + await p.avatarDataUri('https://secure.gravatar.com/avatar/x') + + expect(fetch.mock.calls[0][1]).toEqual({ headers: { Authorization: 'token test-token' } }) + expect(fetch.mock.calls[1][1]).toEqual({ headers: {} }) + }) +}) + +describe('网页地址', () => { + it('工单和 PR 的网页地址', () => { + const p = provider() + expect(p.issueWebUrl(3)).toBe('https://gitea.example/owner/repo/issues/3') + expect(p.pullRequestWebUrl(4)).toBe('https://gitea.example/owner/repo/pulls/4') + }) +}) diff --git a/vscode/src/forge/gitea/provider.ts b/vscode/src/forge/gitea/provider.ts new file mode 100644 index 0000000..0f2a74d --- /dev/null +++ b/vscode/src/forge/gitea/provider.ts @@ -0,0 +1,356 @@ +/** + * Gitea 上的 [ForgeProvider]:直接用 Node 自带的 `fetch` 调 REST 接口,请求带 `Authorization: token ` 头。 + * + * Gitea 的原始 JSON 只在这个文件里出现,出了这个文件都是 [统一类型](vscode/src/forge/types.ts)。 + */ + +import type { + ForgeCapabilities, + ForgeComment, + ForgeIssue, + ForgePrFile, + ForgeProvider, + ForgePullRequest, + ForgeUser, + ForgeUserRef, + IssueFilter, + MergeMethod, + MergeOutcome, + PrFileStatus, +} from '../types' +import { fetchImageDataUri } from '../avatars' +import { ForgeApiError } from '../types' + +/** 每页要 50 条;服务端上限(`MAX_RESPONSE_ITEMS`)可能更小,翻页只看 Link 头,不看这一页满没满。 */ +const PAGE_SIZE = 50 + +interface RawUserRef { + login?: string + full_name?: string + avatar_url?: string +} + +interface RawIssue { + number: number + title: string + state: 'open' | 'closed' + comments: number + html_url: string + closed_at?: string | null + user?: RawUserRef | null + assignees?: RawUserRef[] | null +} + +interface RawComment { + id: number + body?: string + created_at?: string + user?: RawUserRef | null +} + +const CAPABILITIES: ForgeCapabilities = { + deleteIssue: true, + dependencies: true, + mergeMethods: ['merge', 'rebase', 'rebase-merge', 'squash'], +} + +/** Gitea 的文件改动状态有 added、deleted、changed、renamed、copied、unchanged;changed 与 unchanged 都算修改。 */ +function fileStatus(status: unknown): PrFileStatus { + switch (status) { + case 'added': + case 'deleted': + case 'renamed': + case 'copied': + return status + default: + return 'modified' + } +} + +function userRef(raw: RawUserRef): ForgeUserRef { + return { + login: raw.login ?? '', + ...(raw.full_name ? { fullName: raw.full_name } : {}), + ...(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) } : {}), + } +} + +/** Link 头里有没有 rel="next":Gitea 只在还有下一页时给它。 */ +function hasNextPage(res: Response): boolean { + return /rel="next"/.test(res.headers.get('link') ?? '') +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +export class GiteaProvider implements ForgeProvider { + readonly kind = 'gitea' as const + readonly capabilities = CAPABILITIES + + private readonly host: string + private readonly owner: string + private readonly repo: string + private readonly token: string + /** 本实例取到过的 PR 的合并基;Gitea 在 PR 详情里就给了,算 diff 左侧时不用再请求。 */ + private readonly mergeBases = new WeakMap() + + constructor(opts: { host: string, owner: string, repo: string, token: string }) { + this.host = opts.host + this.owner = opts.owner + this.repo = opts.repo + this.token = opts.token + } + + private get repoUrl(): string { + return `https://${this.host}/api/v1/repos/${this.owner}/${this.repo}` + } + + private headers(json = false): Record { + return { + Authorization: `token ${this.token}`, + Accept: 'application/json', + ...(json ? { 'Content-Type': 'application/json' } : {}), + } + } + + private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise { + const hasBody = init.body !== undefined + const res = await fetch(url, { + ...(init.method ? { method: init.method } : {}), + headers: this.headers(hasBody), + ...(hasBody ? { body: JSON.stringify(init.body) } : {}), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new ForgeApiError(res.status, text || res.statusText) + } + return res + } + + private async getJson(url: string): Promise { + return await (await this.send(url)).json() as T + } + + /** 从第 1 页起一页页拉,直到 Link 头里没有下一页。 */ + private async paginate(path: string, params: Record = {}): Promise { + const out: T[] = [] + for (let page = 1; ; page++) { + const url = new URL(`${this.repoUrl}${path}`) + for (const [k, v] of Object.entries(params)) + url.searchParams.set(k, v) + url.searchParams.set('limit', String(PAGE_SIZE)) + url.searchParams.set('page', String(page)) + const res = await this.send(url.toString()) + const batch = await res.json() as T[] + if (!Array.isArray(batch) || batch.length === 0) + return out + out.push(...batch) + if (!hasNextPage(res)) + return out + } + } + + async getCurrentUser(): Promise { + const user = await this.getJson<{ login: string, email?: string }>(`https://${this.host}/api/v1/user`) + return { login: user.login, ...(user.email ? { email: user.email } : {}) } + } + + /** Gitea 的可指派用户接口不分页,一次给全。 */ + async listAssignees(): Promise { + const users = await this.getJson>(`${this.repoUrl}/assignees`) + return users.map(u => ({ login: u.login, ...(u.email ? { email: u.email } : {}) })) + } + + async listIssues(filter: IssueFilter = {}): Promise { + const params: Record = { type: 'issues', state: 'all' } + if (filter.assignee) + params.assigned_by = filter.assignee + if (filter.creator) + params.created_by = filter.creator + return (await this.paginate('/issues', params)).map(toIssue) + } + + async getIssue(issueNumber: number): Promise { + try { + return toIssue(await this.getJson(`${this.repoUrl}/issues/${issueNumber}`)) + } + catch (err) { + if (err instanceof ForgeApiError && err.status === 404) + return null + throw err + } + } + + async setIssueAssignees(issueNumber: number, logins: string[]): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { assignees: logins } }) + } + + async closeIssue(issueNumber: number): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { state: 'closed' } }) + } + + async deleteIssue(issueNumber: number): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'DELETE' }) + } + + /** 单个工单的评论接口不分页,只支持 since / before 时间筛选,一次给全。 */ + async listIssueComments(issueNumber: number): Promise { + const batch = await this.getJson(`${this.repoUrl}/issues/${issueNumber}/comments`) + return Array.isArray(batch) ? batch.map(toComment) : [] + } + + async postIssueComment(issueNumber: number, body: string): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}/comments`, { method: 'POST', body: { body } }) + } + + async listDependencies(issueNumber: number): Promise { + const deps = await this.paginate<{ number?: unknown }>(`/issues/${issueNumber}/dependencies`) + return deps + .map(dep => (typeof dep?.number === 'number' ? dep.number : Number(dep?.number))) + .filter(n => Number.isFinite(n)) + } + + async addDependency(issueNumber: number, dependsOn: number): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies`, { method: 'POST', body: { index: dependsOn } }) + } + + async removeDependency(issueNumber: number, dependsOn: number): Promise { + await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies`, { method: 'DELETE', body: { index: dependsOn } }) + } + + async getPullRequest(prNumber: number): Promise { + const data = await this.getJson<{ + number?: unknown + state?: unknown + merged?: unknown + merged_at?: unknown + html_url?: unknown + body?: unknown + head?: { sha?: unknown, ref?: unknown } + base?: { sha?: unknown, ref?: unknown } + merge_base?: unknown + }>(`${this.repoUrl}/pulls/${prNumber}`) + const pr: ForgePullRequest = { + number: typeof data.number === 'number' ? data.number : Number(data.number), + state: data.state === 'open' ? 'open' : 'closed', + merged: data.merged === true, + ...(typeof data.merged_at === 'string' && data.merged_at ? { mergedAt: data.merged_at } : {}), + htmlUrl: str(data.html_url), + body: str(data.body), + headSha: str(data.head?.sha), + headRef: str(data.head?.ref), + baseSha: str(data.base?.sha), + baseRef: str(data.base?.ref), + } + this.mergeBases.set(pr, str(data.merge_base)) + return pr + } + + async pullRequestDiffBase(pr: ForgePullRequest): Promise { + let mergeBase = this.mergeBases.get(pr) + if (mergeBase === undefined) { + const fresh = await this.getPullRequest(pr.number) + mergeBase = this.mergeBases.get(fresh) ?? '' + } + return mergeBase || pr.baseSha + } + + /** Gitea 不可合并(多半是冲突)回 405,冲突或 head 过期回 409;正文里提到 conflict 的都算冲突。 */ + async mergePullRequest(prNumber: number, method: MergeMethod): Promise { + try { + await this.send(`${this.repoUrl}/pulls/${prNumber}/merge`, { + method: 'POST', + body: { Do: method, delete_branch_after_merge: false, force_merge: false }, + }) + return { merged: true } + } + catch (err) { + if (!(err instanceof ForgeApiError)) + throw err + if (err.status === 405 || /conflict/i.test(err.message)) + return { merged: false, reason: 'conflict', message: err.message } + return { merged: false, reason: err.status === 409 ? 'head-changed' : 'rejected', message: err.message } + } + } + + /** Gitea 把 PR 当工单的一种,关 PR 和关工单走同一个接口。 */ + async closePullRequest(prNumber: number): Promise { + await this.closeIssue(prNumber) + } + + async listPullRequestFiles(prNumber: number): Promise { + const files = await this.paginate<{ + filename?: unknown + previous_filename?: unknown + status?: unknown + additions?: unknown + deletions?: unknown + }>(`/pulls/${prNumber}/files`) + return files.map(f => ({ + path: str(f.filename), + status: fileStatus(f.status), + additions: typeof f.additions === 'number' ? f.additions : 0, + deletions: typeof f.deletions === 'number' ? f.deletions : 0, + ...(typeof f.previous_filename === 'string' && f.previous_filename ? { previousFilename: f.previous_filename } : {}), + })) + } + + /** + * 文件在该 ref 不存在(404)是预期情况:新增文件在旧 ref 缺席、删除文件在新 ref 缺席,返回空串让 diff 自然呈现新增/删除; + * 其它失败同样返回空串,diff 视图容不下错误对话框。 + */ + async getRawFile(path: string, ref: string): Promise { + // encodeURI 保留路径分隔符 `/`,只转义路径段里的特殊字符。 + const url = new URL(`${this.repoUrl}/raw/${encodeURI(path)}`) + url.searchParams.set('ref', ref) + const res = await fetch(url.toString(), { headers: this.headers() }) + return res.ok ? res.text() : '' + } + + async deleteBranch(branch: string): Promise { + await this.send(`${this.repoUrl}/branches/${encodeURIComponent(branch)}`, { method: 'DELETE' }) + } + + /** 头像可能在 Gravatar 这类别家域名上,只有本实例 host 上的才带 token。 */ + avatarDataUri(url: string): Promise { + let sameHost = false + try { + sameHost = new URL(url).host.toLowerCase() === this.host.toLowerCase() + } + catch { + return Promise.resolve(undefined) + } + return fetchImageDataUri(url, sameHost ? { Authorization: `token ${this.token}` } : {}) + } + + issueWebUrl(issueNumber: number): string { + return `https://${this.host}/${this.owner}/${this.repo}/issues/${issueNumber}` + } + + pullRequestWebUrl(prNumber: number): string { + return `https://${this.host}/${this.owner}/${this.repo}/pulls/${prNumber}` + } +} diff --git a/vscode/src/gitea/issueComment.test.ts b/vscode/src/forge/issueComment.test.ts similarity index 100% rename from vscode/src/gitea/issueComment.test.ts rename to vscode/src/forge/issueComment.test.ts diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/forge/issueComment.ts similarity index 100% rename from vscode/src/gitea/issueComment.ts rename to vscode/src/forge/issueComment.ts diff --git a/vscode/src/forge/issueLoader.test.ts b/vscode/src/forge/issueLoader.test.ts new file mode 100644 index 0000000..1fa6f9c --- /dev/null +++ b/vscode/src/forge/issueLoader.test.ts @@ -0,0 +1,162 @@ +import type { ForgeContext } from './context' +import type { ForgeComment, ForgeIssue, ForgeProvider } from './types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCommentsForIssue, loadIssues, loadSingleIssue } from './issueLoader' + +const provider = { + kind: 'gitea', + capabilities: { deleteIssue: true, dependencies: true, mergeMethods: ['merge'] }, + getCurrentUser: vi.fn(), + listIssues: vi.fn(), + getIssue: vi.fn(), + listIssueComments: vi.fn(), + postIssueComment: vi.fn(), + listDependencies: vi.fn(), + getPullRequest: vi.fn(), + avatarDataUri: vi.fn(), +} + +const forge: ForgeContext = { + workspaceRoot: '/repo', + kind: 'gitea', + host: 'gitea.example', + owner: 'o', + repo: 'r', + provider: provider as unknown as ForgeProvider, +} + +function issue(n: number, extra: Partial = {}): ForgeIssue { + return { + number: n, + title: `Issue ${n}`, + state: 'open', + htmlUrl: `https://gitea.example/o/r/issues/${n}`, + commentCount: 0, + author: { login: 'me' }, + assignees: [{ login: 'me' }], + ...extra, + } +} + +function comment(body: string, id: number): ForgeComment { + return { id, body, createdAt: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z` } +} + +beforeEach(() => { + for (const fn of Object.values(provider)) { + if (typeof fn === 'function' && 'mockReset' in fn) + fn.mockReset() + } + provider.capabilities.dependencies = true + provider.getCurrentUser.mockResolvedValue({ login: 'me' }) + provider.listIssues.mockResolvedValue([]) + provider.listIssueComments.mockResolvedValue([]) + provider.listDependencies.mockResolvedValue([]) +}) + +describe('listCommentsForIssue', () => { + it('评论数为 0 时不发请求', async () => { + expect(await listCommentsForIssue(forge.provider, issue(1))).toEqual([]) + expect(provider.listIssueComments).not.toHaveBeenCalled() + }) + + it('超过 50 条评论时一次读取,完整保留状态及其后的讨论', async () => { + const comments = [ + comment(JSON.stringify({ column: 'in-progress' }), 1), + ...Array.from({ length: 77 }, (_, i) => comment('later discussion', i + 2)), + ] + provider.listIssueComments.mockResolvedValue(comments) + + const out = await listCommentsForIssue(forge.provider, issue(1, { commentCount: 78 })) + + expect(provider.listIssueComments).toHaveBeenCalledOnce() + expect(out).toEqual(comments) + }) +}) + +describe('loadIssues', () => { + it('只读取指定工单的评论', async () => { + provider.listIssues.mockResolvedValue([issue(7, { commentCount: 3 })]) + provider.listIssueComments.mockResolvedValue([comment(JSON.stringify({ column: 'todo' }), 1)]) + + const { issues, me } = await loadIssues(forge, 'all') + + expect(me).toBe('me') + expect(provider.listIssueComments).toHaveBeenCalledWith(7) + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ id: 'o/r#7', column: 'todo', assignees: ['me'], poster: 'me' }) + }) + + it('mine 视图按指派人和创建者各拉一次再合并', async () => { + provider.listIssues.mockImplementation(async (filter: { assignee?: string, creator?: string }) => + filter.assignee ? [issue(1), issue(2)] : [issue(2), issue(3)]) + + const { issues } = await loadIssues(forge) + + expect(provider.listIssues).toHaveBeenCalledWith({ assignee: 'me' }) + expect(provider.listIssues).toHaveBeenCalledWith({ creator: 'me' }) + expect(issues.map(i => i.number)).toEqual([1, 2, 3]) + }) + + it('缺状态评论时用默认列,不代别人发评论', async () => { + provider.listIssues.mockResolvedValue([issue(3, { author: { login: 'cruldra' } })]) + + const { issues } = await loadIssues(forge, 'all') + + expect(issues[0].column).toBe('todo') + expect(provider.postIssueComment).not.toHaveBeenCalled() + }) + + it('认隐藏格式,最新一条有效的状态胜出', async () => { + provider.listIssues.mockResolvedValue([issue(8, { commentCount: 3 })]) + provider.listIssueComments.mockResolvedValue([ + comment('{"column":"todo"}', 1), + comment('', 2), + comment('普通讨论', 3), + ]) + + const { issues } = await loadIssues(forge, 'all') + + expect(issues[0]).toMatchObject({ column: 'review', branch: 'feature/x', specFile: 'docs/a.md' }) + }) + + it('仓库没有依赖功能时不查前置任务', async () => { + provider.capabilities.dependencies = false + provider.listIssues.mockResolvedValue([issue(4)]) + + const { issues } = await loadIssues(forge, 'all') + + expect(provider.listDependencies).not.toHaveBeenCalled() + expect(issues[0].prerequisite).toBeUndefined() + }) + + it('非完成工单实时查 PR 合并状态,完成工单不查', async () => { + provider.listIssues.mockResolvedValue([issue(5, { commentCount: 1 }), issue(6, { commentCount: 1 })]) + provider.listIssueComments.mockImplementation(async (n: number) => [comment(JSON.stringify(n === 5 ? { column: 'review', pr: '50' } : { column: 'done', pr: '60' }), 1)]) + provider.getPullRequest.mockResolvedValue({ merged: true, mergedAt: '2024-01-01T00:00:00Z' }) + provider.listDependencies.mockResolvedValue([2, 1]) + + const { issues } = await loadIssues(forge, 'all') + + expect(provider.getPullRequest).toHaveBeenCalledOnce() + expect(provider.getPullRequest).toHaveBeenCalledWith(50) + expect(issues[0]).toMatchObject({ prMerged: true, prMergedAt: '2024-01-01T00:00:00Z', prerequisite: 2 }) + expect(issues[1].prerequisite).toBeUndefined() + }) +}) + +describe('loadSingleIssue', () => { + it('工单不存在时返回 null', async () => { + provider.getIssue.mockResolvedValue(null) + expect(await loadSingleIssue(forge, 9)).toBeNull() + }) + + it('读评论和前置任务拼出一张卡', async () => { + provider.getIssue.mockResolvedValue(issue(9, { state: 'closed', closedAt: '2024-02-02T00:00:00Z' })) + provider.listIssueComments.mockResolvedValue([comment(JSON.stringify({ pr: '12' }), 1)]) + provider.listDependencies.mockResolvedValue([4]) + provider.getPullRequest.mockRejectedValue(new Error('network')) + + expect(await loadSingleIssue(forge, 9)).toMatchObject({ number: 9, column: 'done', pr: '12', prerequisite: 4, closedAt: '2024-02-02T00:00:00Z' }) + }) +}) diff --git a/vscode/src/forge/issueLoader.ts b/vscode/src/forge/issueLoader.ts new file mode 100644 index 0000000..15789b5 --- /dev/null +++ b/vscode/src/forge/issueLoader.ts @@ -0,0 +1,234 @@ +/** + * 从代码托管仓库加载看板工单:工单列表来自 [ForgeProvider],每张卡的列和元数据来自工单上最新的状态评论。 + * + * 流程: + * 1. 拉工单列表(mine:先取当前用户再拉指派给我的和我创建的;all:工单与当前用户并行)。 + * 2. 每张工单读一次全部评论,从中解析状态;不拉整仓库评论。缺状态时只在内存里用默认列,不发评论。 + * 3. 非完成工单并行补依赖和 PR 状态。 + */ + +import type { Issue, IssueColumn } from '../issues/types' +import type { ForgeContext } from './context' +import type { ForgeComment, ForgeIssue, ForgeProvider } from './types' +import * as fs from 'node:fs' +import { resolveWorktreePath } from '../git/worktree' +import { latestState } from '../issues/stateComment' +import { issueStateFields } from '../issues/stateFields' +import { resolveAvatars } from './avatars' + +function defaultColumnForState(state: ForgeIssue['state']): IssueColumn { + return state === 'open' ? 'todo' : 'done' +} + +/** 工单当前状态:评论里最新一条状态评论,没有就是空对象。 */ +function stateOf(comments: ForgeComment[]): Record { + return latestState(comments.map(comment => comment.body)) ?? {} +} + +/** 按工单号合并几份列表,同号的留先出现的那份。 */ +function mergeIssues(...lists: ForgeIssue[][]): ForgeIssue[] { + const map = new Map() + for (const list of lists) { + for (const issue of list) { + if (!map.has(issue.number)) + map.set(issue.number, issue) + } + } + return [...map.values()] +} + +const COMMENT_FETCH_CONCURRENCY = 8 + +async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = Array.from({ length: items.length }) + let next = 0 + async function worker(): Promise { + while (true) { + const i = next++ + if (i >= items.length) + return + out[i] = await fn(items[i]) + } + } + const n = Math.min(Math.max(limit, 1), items.length) + if (n === 0) + return out + await Promise.all(Array.from({ length: n }, () => worker())) + return out +} + +/** 读一张工单的全部评论来解析状态;评论数为 0 时不发请求。 */ +export async function listCommentsForIssue(provider: ForgeProvider, issue: ForgeIssue): Promise { + if (issue.commentCount <= 0) + return [] + return provider.listIssueComments(issue.number) +} + +/** 第一个前置任务的工单号;仓库没有依赖功能或查询失败(依赖被关掉、网络等)都当没有,一张工单出错不拖垮整个看板。 */ +async function firstDependency(provider: ForgeProvider, issueNumber: number): Promise { + if (!provider.capabilities.dependencies) + return undefined + try { + const deps = await provider.listDependencies(issueNumber) + return deps[0] + } + catch { + return undefined + } +} + +interface PrStatus { + merged?: boolean + mergedAt?: string +} + +/** + * 实时查 PR 合没合、什么时候合的;没有 PR、编号不合法或查询失败都返回空对象,调用方退回状态里存的值。 + * `mergedAt` 给完成列按合并时间排序用。 + */ +async function fetchPrStatus(provider: ForgeProvider, pr: string | undefined): Promise { + if (!pr) + return {} + const index = Number.parseInt(pr, 10) + if (!Number.isFinite(index) || index <= 0) + return {} + try { + const data = await provider.getPullRequest(index) + return { merged: data.merged, mergedAt: data.mergedAt } + } + catch { + return {} + } +} + +function buildIssue(opts: { + forge: ForgeContext + issue: ForgeIssue + comments: ForgeComment[] + prerequisite: number | undefined + /** 实时 PR 合并状态;undefined 表示查询失败或没有 PR,退回状态里存的值。 */ + liveMerged?: boolean + /** PR 合并时间,只给完成列排序用;查询失败或没有 PR 时为 undefined。 */ + liveMergedAt?: string +}): Issue { + const { forge, issue, comments, prerequisite, liveMerged, liveMergedAt } = opts + const { + column: fromComment, + prMerged: persistedMerged, + prMergedAt: persistedMergedAt, + ...stateFields + } = issueStateFields(stateOf(comments)) + const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged + // 完成工单不再实时拉 PR,排序用的合并时间退回状态里存的值。 + const mergedAt = liveMergedAt ?? persistedMergedAt + // 缺状态时只在本机按工单开/关给默认列,不在加载时补评论:谁的看板先刷到,评论就挂在谁名下。 + const column = fromComment ?? defaultColumnForState(issue.state) + + // 同步查一下 worktree 在不在,界面据此显示成可点的链接还是纯文本。 + let worktreeExists: boolean | undefined + if (stateFields.worktreePath) { + try { + worktreeExists = fs.existsSync(resolveWorktreePath(stateFields.worktreePath, forge.workspaceRoot)) + } + catch { + worktreeExists = false + } + } + + return { + id: `${forge.owner}/${forge.repo}#${issue.number}`, + number: issue.number, + title: issue.title, + column, + ...stateFields, + ...(prMerged !== undefined ? { prMerged } : {}), + ...(mergedAt ? { prMergedAt: mergedAt } : {}), + ...(issue.closedAt ? { closedAt: issue.closedAt } : {}), + ...(worktreeExists !== undefined ? { worktreeExists } : {}), + ...(prerequisite !== undefined ? { prerequisite } : {}), + assignees: issue.assignees.map(a => a.login), + ...(issue.author?.login ? { poster: issue.author.login } : {}), + htmlUrl: issue.htmlUrl, + } +} + +/** + * 加载看板上的全部工单。 + * + * @param forge 要加载的仓库 + * @param scope `mine`(默认)只要指派给我或我创建的;`all` 仓库全部工单(团队视图) + * @return 工单、当前用户 login、团队视图卡片要贴的头像(login → data URI) + */ +export async function loadIssues( + forge: ForgeContext, + scope: 'mine' | 'all' = 'mine', +): Promise<{ issues: Issue[], me: string, avatars: Record }> { + const { provider } = forge + + let listed: ForgeIssue[] + let me: string + if (scope === 'all') { + const [all, user] = await Promise.all([provider.listIssues(), provider.getCurrentUser()]) + listed = all + me = user.login + } + else { + me = (await provider.getCurrentUser()).login + const [assigned, created] = await Promise.all([ + provider.listIssues({ assignee: me }), + provider.listIssues({ creator: me }), + ]) + listed = mergeIssues(assigned, created) + } + + const commentLists = await mapPool(listed, COMMENT_FETCH_CONCURRENCY, issue => listCommentsForIssue(provider, issue)) + + // 完成工单的前置依赖没有意义、PR 状态也不会再变,跳过它们的依赖和 PR 查询是看板提速的关键:完成列动辄上百张。 + // 这里的列与 buildIssue 的口径一致:状态评论优先,缺失时按工单开/关给默认列。 + const states = commentLists.map(stateOf) + const columns = listed.map((issue, i) => issueStateFields(states[i]).column ?? defaultColumnForState(issue.state)) + + const [prerequisites, livePrs] = await Promise.all([ + Promise.all(listed.map((issue, i) => columns[i] === 'done' ? undefined : firstDependency(provider, issue.number))), + Promise.all(listed.map((_issue, i): PrStatus | Promise => columns[i] === 'done' ? {} : fetchPrStatus(provider, issueStateFields(states[i]).pr))), + ]) + + const issues = listed.map((issue, i) => buildIssue({ + forge, + issue, + comments: commentLists[i], + prerequisite: prerequisites[i], + liveMerged: livePrs[i].merged, + liveMergedAt: livePrs[i].mergedAt, + })) + + // 团队视图下卡片按人贴头像;抓不到头像不算失败,前端回退首字母。 + const avatars = await resolveAvatars( + provider, + listed.flatMap(issue => [ + ...issue.assignees.map(a => [a.login, a.avatarUrl] as const), + ...(issue.author ? [[issue.author.login, issue.author.avatarUrl] as const] : []), + ]), + ) + + return { issues, me, avatars } +} + +/** + * 按号加载一张工单,列和元数据的算法同 [loadIssues];工单不存在时返回 `null`。 + * 新建工单的增量路径用它,webview 只追加一张卡,不用重画整个看板。 + */ +export async function loadSingleIssue(forge: ForgeContext, issueNumber: number): Promise { + const { provider } = forge + const issue = await provider.getIssue(issueNumber) + if (!issue) + return null + + const [comments, prerequisite] = await Promise.all([ + provider.listIssueComments(issueNumber), + firstDependency(provider, issueNumber), + ]) + const live = await fetchPrStatus(provider, issueStateFields(stateOf(comments)).pr) + + return buildIssue({ forge, issue, comments, prerequisite, liveMerged: live.merged, liveMergedAt: live.mergedAt }) +} diff --git a/vscode/src/gitea/markers.test.ts b/vscode/src/forge/markers.test.ts similarity index 100% rename from vscode/src/gitea/markers.test.ts rename to vscode/src/forge/markers.test.ts diff --git a/vscode/src/gitea/markers.ts b/vscode/src/forge/markers.ts similarity index 100% rename from vscode/src/gitea/markers.ts rename to vscode/src/forge/markers.ts diff --git a/vscode/src/forge/state.ts b/vscode/src/forge/state.ts new file mode 100644 index 0000000..f40b667 --- /dev/null +++ b/vscode/src/forge/state.ts @@ -0,0 +1,39 @@ +/** + * 代码托管仓库工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.ts)。 + */ + +import type { StateCommentThread } from '../issues/stateComment' +import type { ForgeProvider } from './types' +import { mergeState, mergeStateGuarded, readState } from '../issues/stateComment' + +function forgeThread(provider: ForgeProvider, issueNumber: number): StateCommentThread { + return { + bodies: async () => (await provider.listIssueComments(issueNumber)).map(comment => comment.body), + post: body => provider.postIssueComment(issueNumber, body), + } +} + +/** 读工单当前状态;没有状态评论时是空对象,接口失败直接抛出。 */ +export function readForgeState(provider: ForgeProvider, issueNumber: number): Promise> { + return readState(forgeThread(provider, issueNumber)) +} + +/** 把 `extra` 合并进工单状态并写一条新的状态评论;`defaults` 只补缺的字段,接口失败直接抛出。 */ +export function mergeForgeState( + provider: ForgeProvider, + issueNumber: number, + extra: Record, + defaults?: Record, +): Promise { + return mergeState(forgeThread(provider, issueNumber), extra, defaults) +} + +/** 同 [mergeForgeState],`protectDoneColumn` 时不让自动流程把已完成的卡改回别的列。 */ +export function mergeForgeStateGuarded( + provider: ForgeProvider, + issueNumber: number, + extra: Record, + opts: { protectDoneColumn?: boolean }, +): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { + return mergeStateGuarded(forgeThread(provider, issueNumber), extra, opts) +} diff --git a/vscode/src/forge/types.ts b/vscode/src/forge/types.ts new file mode 100644 index 0000000..ef83d46 --- /dev/null +++ b/vscode/src/forge/types.ts @@ -0,0 +1,169 @@ +/** + * 代码托管仓库(forge)的统一接口:工单、评论、PR、分支这些操作在 Gitea 和 GitHub 上各有一份实现, + * 调用方只认这里的类型,不碰任何一家的接口字段和 HTTP 状态码。 + */ + +/** 哪一家代码托管:host 是 `github.com` 的是 GitHub,其余都按 Gitea 处理。 */ +export type ForgeKind = 'gitea' | 'github' + +/** 当前登录用户或可指派的用户。 */ +export interface ForgeUser { + login: string + email?: string +} + +/** 工单、评论上出现的一个人。 */ +export interface ForgeUserRef { + login: string + fullName?: string + /** 头像原始地址;要显示时交给 [ForgeProvider.avatarDataUri] 转成 data URI。 */ + avatarUrl?: string +} + +export interface ForgeIssue { + number: number + title: string + state: 'open' | 'closed' + htmlUrl: string + /** 评论条数;为 0 时加载看板不必去拉评论。 */ + commentCount: number + /** 关闭时间(ISO 8601),没关的工单没有。 */ + closedAt?: string + author?: ForgeUserRef + assignees: ForgeUserRef[] +} + +export interface ForgeComment { + id: number + body: string + createdAt: string + author?: ForgeUserRef +} + +/** 列工单时按人过滤;两个都不给就是仓库全部工单。 */ +export interface IssueFilter { + /** 只要指派给这个 login 的。 */ + assignee?: string + /** 只要这个 login 创建的。 */ + creator?: string +} + +export interface ForgePullRequest { + number: number + /** 合并了的 PR 也是 `closed`,要分清看 [merged]。 */ + state: 'open' | 'closed' + merged: boolean + /** 合并时间(ISO 8601),没合并的 PR 没有。 */ + mergedAt?: string + htmlUrl: string + /** PR 描述;webhook 靠里面的 `Closes #N` 反查工单号。 */ + body: string + /** head 分支最新提交:PR diff 的右侧。 */ + headSha: string + /** head 分支名,如 `feature/abcd`。 */ + headRef: string + baseSha: string + /** 要合进去的目标分支名,如 `master`。 */ + baseRef: string +} + +/** PR 里一个文件的改动类型,各家的叫法在实现里统一成这几种。 */ +export type PrFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' + +export interface ForgePrFile { + path: string + status: PrFileStatus + additions: number + deletions: number + /** 改名或复制前的路径,取 diff 左侧内容用。 */ + previousFilename?: string +} + +/** 合并 PR 的方式;各家支持哪些见 [ForgeCapabilities.mergeMethods]。 */ +export type MergeMethod = 'merge' | 'squash' | 'rebase' | 'rebase-merge' + +/** + * 合并 PR 的结果。没合上时 `reason` 说明原因:`conflict` 有冲突或当前不可合并,`head-changed` 合并期间 head 分支又有了新提交, + * `rejected` 其它被拒绝的情况(已关闭、没权限等)。 + */ +export type MergeOutcome + = | { merged: true } + | { merged: false, reason: 'conflict' | 'head-changed' | 'rejected', message: string } + +/** 各家能力的差别,调用方据此决定一个操作做不做、怎么做。 */ +export interface ForgeCapabilities { + /** 能不能删除工单。 */ + deleteIssue: boolean + /** 有没有工单依赖(前置任务)。 */ + dependencies: boolean + /** 支持的合并方式,第一个是默认用的。 */ + mergeMethods: readonly MergeMethod[] +} + +/** 代码托管接口返回非 2xx 时抛出,`status` 是 HTTP 状态码。 */ +export class ForgeApiError extends Error { + readonly status: number + + constructor(status: number, message: string) { + super(message) + this.name = 'ForgeApiError' + this.status = status + } +} + +/** + * 一个仓库上的全部代码托管操作,凭据和仓库坐标在创建时就定了。 + * + * 除注明的以外,接口失败都抛 [ForgeApiError],网络失败原样抛出。 + */ +export interface ForgeProvider { + readonly kind: ForgeKind + readonly capabilities: ForgeCapabilities + + getCurrentUser: () => Promise + /** 能被指派工单的用户,移交选人用。 */ + listAssignees: () => Promise + + /** 列工单(不含 PR),开着的和关掉的都要,翻完所有页。 */ + listIssues: (filter?: IssueFilter) => Promise + /** 工单不存在时返回 `null`。 */ + getIssue: (issueNumber: number) => Promise + /** 把指派人整个换成 `logins`。 */ + setIssueAssignees: (issueNumber: number, logins: string[]) => Promise + closeIssue: (issueNumber: number) => Promise + /** 只在 [ForgeCapabilities.deleteIssue] 为真时调用。 */ + deleteIssue: (issueNumber: number) => Promise + /** 工单的全部评论,从旧到新。 */ + listIssueComments: (issueNumber: number) => Promise + postIssueComment: (issueNumber: number, body: string) => Promise + + /** 挡住这个工单的工单编号(前置任务),按创建先后。 */ + listDependencies: (issueNumber: number) => Promise + /** 让 `dependsOn` 号工单挡住 `issueNumber` 号工单;参数都是工单编号,各家内部的 id 换算由实现负责。 */ + addDependency: (issueNumber: number, dependsOn: number) => Promise + removeDependency: (issueNumber: number, dependsOn: number) => Promise + + getPullRequest: (prNumber: number) => Promise + /** + * PR diff 左侧要用的提交:目标分支与 head 的合并基,与网页上的 PR diff 一致;取不到时退回目标分支的提交。 + * + * `pr` 最好传 [getPullRequest] 刚拿到的那个对象,有的实现能省掉一次请求。 + */ + pullRequestDiffBase: (pr: ForgePullRequest) => Promise + /** 合并 PR;接口拒绝时不抛错,在结果里给出原因,只有网络失败才抛。合并后不删分支。 */ + mergePullRequest: (prNumber: number, method: MergeMethod) => Promise + /** 关掉 PR,不合并。 */ + closePullRequest: (prNumber: number) => Promise + /** PR 改过的全部文件,翻完所有页。 */ + listPullRequestFiles: (prNumber: number) => Promise + /** 文件在 `ref` 上的原文;文件不存在或取不到时返回空串,不抛错。 */ + getRawFile: (path: string, ref: string) => Promise + /** 删远程分支,`branch` 不带 `refs/heads/`。 */ + deleteBranch: (branch: string) => Promise + + /** 把头像地址抓成 data URI 给 webview 用;只对本仓库所在的 host 带凭据,失败返回 `undefined`。 */ + avatarDataUri: (url: string) => Promise + + issueWebUrl: (issueNumber: number) => string + pullRequestWebUrl: (prNumber: number) => string +} diff --git a/vscode/src/git/remote.ts b/vscode/src/git/remote.ts index a62dd0d..52cb6f6 100644 --- a/vscode/src/git/remote.ts +++ b/vscode/src/git/remote.ts @@ -1,5 +1,5 @@ /** - * Detects the workspace's Gitea repo coordinates from `git remote get-url origin`. + * Detects the workspace's forge repo coordinates (host / owner / repo) from `git remote get-url origin`. * * Supports both HTTPS forms (`https://gitea.x.cn/owner/repo.git`) and SSH * forms (`git@gitea.x.cn:owner/repo.git`, with or without a leading diff --git a/vscode/src/gitea/api.comments.test.ts b/vscode/src/gitea/api.comments.test.ts deleted file mode 100644 index d91eae8..0000000 --- a/vscode/src/gitea/api.comments.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('../logging/logger', () => ({ logger: { add: vi.fn() } })) - -afterEach(() => vi.unstubAllGlobals()) - -describe('单个工单评论接口', () => { - it.each([0, 50, 78])('返回 %i 条评论时只发一次请求,不传分页参数', async (count) => { - const { listIssueComments } = await import('./api.js') - const comments = Array.from({ length: count }, (_, i) => ({ id: i + 1, body: `评论 ${i}` })) - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(comments))) - vi.stubGlobal('fetch', fetch) - - const result = await listIssueComments({ - host: 'gitea.example', - token: 'test-token', - owner: 'owner', - repo: 'repo', - index: 408, - }) - - expect(result).toEqual(comments) - expect(fetch).toHaveBeenCalledOnce() - expect(fetch).toHaveBeenCalledWith( - 'https://gitea.example/api/v1/repos/owner/repo/issues/408/comments', - expect.objectContaining({ headers: expect.any(Object) }), - ) - }) -}) diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts deleted file mode 100644 index f131bab..0000000 --- a/vscode/src/gitea/api.ts +++ /dev/null @@ -1,563 +0,0 @@ -/** - * Fetch-based Gitea API client. - * - * Replaces the previous `tea` CLI shell-outs with direct REST calls over - * Node 20+'s global `fetch`. All requests are authenticated with a personal - * access token via the `Authorization: token ` header. - * - * Errors from non-2xx responses surface as `GiteaApiError` so callers can - * distinguish 401 (token invalid) from other failure modes. - */ - -import { logger } from '../logging/logger' - -export const PAGE_SIZE = 50 - -export interface GiteaUser { - login: string - id: number - email?: string -} - -export interface GiteaIssue { - id: number - number: number - title: string - state: 'open' | 'closed' - comments: number - created_at: string - updated_at: string - /** 工单关闭时间(ISO 8601);未关闭为 null。完成列缺 PR 合并时间时用它排序。 */ - closed_at?: string | null - body: string - html_url: string - user: { login: string, avatar_url?: string } | null - assignees: Array<{ login: string, avatar_url?: string }> | null - labels: Array<{ name: string, color: string }> | null -} - -export interface GiteaComment { - id: number - body: string - issue_url: string - created_at: string - updated_at: string - user?: { - id: number - login: string - full_name?: string - avatar_url?: string - } -} - -export class GiteaApiError extends Error { - readonly status: number - - constructor(status: number, message: string) { - super(message) - this.name = 'GiteaApiError' - this.status = status - } -} - -function baseUrl(host: string): string { - return `https://${host}/api/v1` -} - -function authHeaders(token: string): Record { - return { - Authorization: `token ${token}`, - Accept: 'application/json', - } -} - -async function ensureOk(res: Response): Promise { - if (res.ok) - return - const body = await res.text().catch(() => '') - throw new GiteaApiError(res.status, body || res.statusText) -} - -export async function getCurrentUser(opts: { host: string, token: string }): Promise { - const res = await fetch(`${baseUrl(opts.host)}/user`, { - headers: authHeaders(opts.token), - }) - await ensureOk(res) - return res.json() as Promise -} - -/** - * Fetches `/repos/{owner}/{repo}/issues` filtered by either `assigned_by` or - * `created_by`, paginating until a short page is returned. - */ -export async function listIssuesByFilter(opts: { - host: string - token: string - owner: string - repo: string - /** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */ - filter?: 'assigned_by' | 'created_by' - user?: string -}): Promise { - const out: GiteaIssue[] = [] - let page = 1 - // Gitea returns a JSON array of issues; we accumulate until we see a short page. - - while (true) { - const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`) - url.searchParams.set('type', 'issues') - url.searchParams.set('state', 'all') - if (opts.filter && opts.user) - url.searchParams.set(opts.filter, opts.user) - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(page)) - - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as GiteaIssue[] - if (!Array.isArray(batch) || batch.length === 0) - break - out.push(...batch) - if (batch.length < PAGE_SIZE) - break - page += 1 - } - return out -} - -/** - * 单个工单的评论接口一次返回全部评论,只支持 since / before 时间筛选。 - */ -export async function listIssueComments(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const url = new URL( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, - ) - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as GiteaComment[] - return Array.isArray(batch) ? batch : [] -} - -export async function postIssueComment(opts: { - host: string - token: string - owner: string - repo: string - index: number - body: string -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, - { - method: 'POST', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body: opts.body }), - }, - ) - await ensureOk(res) -} - -export interface GiteaPullRequest { - number: number - merged: boolean - state: string - merged_at?: string | null - html_url: string - /** - * PR description / body. Used by the webhook coordinator to reverse-lookup - * the underlying issue number (`Closes #N`) when a comment fires on a PR. - */ - body: string - /** head 分支最新提交 sha:PR diff 的右侧 ref。 */ - headSha: string - /** head 分支名,如 `feature/abcd`;完成列清理分支时作 state.branch 兜底。 */ - headRef: string - /** base 分支 sha:mergeBase 取不到时的左侧 ref 兜底。 */ - baseSha: string - /** base 分支名(PR 要合进去的目标分支),如 `master`。 */ - baseRef: string - /** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */ - mergeBase: string -} - -/** - * Fetches a single pull request by index via - * `/repos/{owner}/{repo}/pulls/{index}`. Used by the kanban "done" drop - * handler to verify a PR is merged before persisting `column='done'`. - */ -export async function getPullRequest(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}`, - { headers: authHeaders(opts.token) }, - ) - await ensureOk(res) - const data = await res.json() as { - number?: unknown - merged?: unknown - state?: unknown - merged_at?: unknown - html_url?: unknown - body?: unknown - head?: { sha?: unknown, ref?: unknown } - base?: { sha?: unknown, ref?: unknown } - merge_base?: unknown - } - return { - number: typeof data.number === 'number' ? data.number : Number(data.number), - merged: data.merged === true, - state: typeof data.state === 'string' ? data.state : '', - merged_at: typeof data.merged_at === 'string' ? data.merged_at : null, - html_url: typeof data.html_url === 'string' ? data.html_url : '', - body: typeof data.body === 'string' ? data.body : '', - headSha: typeof data.head?.sha === 'string' ? data.head.sha : '', - headRef: typeof data.head?.ref === 'string' ? data.head.ref : '', - baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '', - baseRef: typeof data.base?.ref === 'string' ? data.base.ref : '', - mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '', - } -} - -export async function getIssue(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`, - { headers: authHeaders(opts.token) }, - ) - if (res.status === 404) - return null - await ensureOk(res) - return await res.json() as GiteaIssue -} - -/** - * 覆盖式设置工单 assignee(实施即认领的多人协作语义:单一责任人, - * 重新实施会把责任转移给新实施者)。 - */ -export async function updateIssueAssignees(opts: { - host: string - token: string - owner: string - repo: string - index: number - assignees: string[] -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`, - { - method: 'PATCH', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ assignees: opts.assignees }), - }, - ) - await ensureOk(res) -} - -export async function getDependencies(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise> { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`, - { headers: authHeaders(opts.token) }, - ) - await ensureOk(res) - const data = await res.json() as Array<{ number?: unknown }> - if (!Array.isArray(data)) - return [] - return data - .map((it) => { - const n = typeof it?.number === 'number' ? it.number : Number(it?.number) - return Number.isFinite(n) ? { number: n } : null - }) - .filter((it): it is { number: number } => it !== null) -} - -export async function addDependency(opts: { - host: string - token: string - owner: string - repo: string - index: number - dependencyIndex: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`, - { - method: 'POST', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ index: opts.dependencyIndex }), - }, - ) - await ensureOk(res) -} - -/** - * Merge a pull request via gitea's `POST /repos/{owner}/{repo}/pulls/{index}/merge`. - * Defaults to the plain "merge" strategy. Throws GiteaApiError on non-2xx. - */ -export async function mergePullRequest(opts: { - host: string - token: string - owner: string - repo: string - index: number - strategy?: 'merge' | 'rebase' | 'rebase-merge' | 'squash' -}): Promise { - const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/merge` - const body = { - Do: opts.strategy ?? 'merge', - delete_branch_after_merge: false, - force_merge: false, - } - const res = await fetch(url, { - method: 'POST', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) - await ensureOk(res) -} - -export async function closeIssue(opts: { - host: string - token: string - owner: string - repo: string - issueNumber: number -}): Promise { - const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}` - const res = await fetch(url, { - method: 'PATCH', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ state: 'closed' }), - }) - await ensureOk(res) -} - -export async function deleteIssue(opts: { - host: string - token: string - owner: string - repo: string - issueNumber: number -}): Promise { - const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}` - const res = await fetch(url, { - method: 'DELETE', - headers: authHeaders(opts.token), - }) - await ensureOk(res) -} - -export async function deleteBranch(opts: { - host: string - token: string - owner: string - repo: string - branch: string -}): Promise { - const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/branches/${encodeURIComponent(opts.branch)}` - const res = await fetch(url, { - method: 'DELETE', - headers: authHeaders(opts.token), - }) - await ensureOk(res) -} - -export async function removeDependency(opts: { - host: string - token: string - owner: string - repo: string - index: number - dependencyIndex: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`, - { - method: 'DELETE', - headers: { - ...authHeaders(opts.token), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ index: opts.dependencyIndex }), - }, - ) - await ensureOk(res) -} - -// no longer auto-invoked by the implement flow; kept for future manual ops -export async function deleteWebhook(opts: { - host: string - token: string - owner: string - repo: string - hookId: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/hooks/${opts.hookId}`, - { - method: 'DELETE', - headers: authHeaders(opts.token), - }, - ) - if (res.status === 404) { - logger.add({ - level: 'warn', - source: 'gitea', - message: `deleteWebhook hookId=${opts.hookId} 已不存在 (404)`, - }) - return - } - if (!res.ok) { - const body = await res.text().catch(() => '') - logger.add({ - level: 'error', - source: 'gitea', - message: `deleteWebhook hookId=${opts.hookId} 失败 status=${res.status}`, - details: (body || res.statusText).slice(0, 500), - }) - throw new GiteaApiError(res.status, body || res.statusText) - } - logger.add({ - level: 'info', - source: 'gitea', - message: `已删除 hookId=${opts.hookId}`, - }) -} - -/** PR 改动里的一个文件:path/status 给文件树,additions/deletions 求和成统计条,previousFilename 用于改名时取左侧旧路径。 */ -export interface PrFileDto { - path: string - status: string - additions: number - deletions: number - previousFilename?: string -} - -/** - * 列出某 PR 的全部改动文件(三点 diff,merge_base → head,与 Gitea 网页 diff 口径一致)。 - * 分页拉到短页为止;改名文件带 previousFilename(旧路径,取左侧内容用)。 - */ -export async function listPullRequestFiles(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const out: PrFileDto[] = [] - let page = 1 - - while (true) { - const url = new URL( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`, - ) - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(page)) - - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as Array<{ - filename?: unknown - previous_filename?: unknown - status?: unknown - additions?: unknown - deletions?: unknown - }> - if (!Array.isArray(batch) || batch.length === 0) - break - for (const f of batch) { - const prev = typeof f.previous_filename === 'string' && f.previous_filename.length > 0 - ? f.previous_filename - : undefined - out.push({ - path: typeof f.filename === 'string' ? f.filename : '', - status: typeof f.status === 'string' ? f.status : '', - additions: typeof f.additions === 'number' ? f.additions : 0, - deletions: typeof f.deletions === 'number' ? f.deletions : 0, - previousFilename: prev, - }) - } - if (batch.length < PAGE_SIZE) - break - page += 1 - } - return out -} - -/** - * 取某 ref 下文件的原始内容,用作 diff 的一侧。 - * - * 文件在该 ref 不存在(404)是预期情况:新增文件在父 ref 缺席、删除文件在子 - * ref 缺席。这类情况返回空串,让 diff 自然呈现新增/删除,而非报错。其它非 2xx - * 同样兜底返回空串——diff 视图容不下错误对话框。 - */ -export async function getRawFile(opts: { - host: string - token: string - owner: string - repo: string - filepath: string - ref: string -}): Promise { - // encodeURI 保留路径分隔符 `/`,只转义路径段里的特殊字符。 - const url = new URL( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/raw/${encodeURI(opts.filepath)}`, - ) - url.searchParams.set('ref', opts.ref) - - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - if (!res.ok) - return '' - return res.text() -} - -/** 可被指派的用户(仓库协作者 + owner),移交选人用。 */ -export async function listRepoAssignees(opts: { - host: string - token: string - owner: string - repo: string -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/assignees`, - { headers: authHeaders(opts.token) }, - ) - await ensureOk(res) - return (await res.json()) as GiteaUser[] -} diff --git a/vscode/src/gitea/issueLoader.test.ts b/vscode/src/gitea/issueLoader.test.ts deleted file mode 100644 index ab6f67a..0000000 --- a/vscode/src/gitea/issueLoader.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { GiteaComment, GiteaIssue } from './api' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const getCurrentUser = vi.fn() -const listIssuesByFilter = vi.fn() -const getDependencies = vi.fn() -const getPullRequest = vi.fn() -const postIssueComment = vi.fn() -const getIssue = vi.fn() -const listIssueComments = vi.fn() - -vi.mock('./api', () => ({ - PAGE_SIZE: 50, - getCurrentUser, - listIssuesByFilter, - getDependencies, - getPullRequest, - postIssueComment, - getIssue, - listIssueComments, -})) - -function issue(n: number, extra: Partial = {}): GiteaIssue { - return { - id: n, - number: n, - title: `Issue ${n}`, - state: 'open', - comments: 0, - created_at: '2020-01-01T00:00:00Z', - updated_at: '2020-01-01T00:00:00Z', - body: '', - html_url: `https://gitea.example/o/r/issues/${n}`, - user: { login: 'me' }, - assignees: [{ login: 'me' }], - labels: [], - ...extra, - } -} - -function comment(body: string, index: number, id: number): GiteaComment { - return { - id, - body, - issue_url: `https://gitea.example/api/v1/repos/o/r/issues/${index}`, - created_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`, - updated_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`, - } -} - -describe('listCommentsForIssue', () => { - beforeEach(() => { - listIssueComments.mockReset() - }) - - it('commentCount 为 0 时不发请求', async () => { - const { listCommentsForIssue } = await import('./issueLoader.js') - const out = await listCommentsForIssue({ - host: 'gitea.example', - token: 't', - owner: 'o', - repo: 'r', - index: 1, - commentCount: 0, - }) - expect(out).toEqual([]) - expect(listIssueComments).not.toHaveBeenCalled() - }) - - it('超过 50 条评论时一次读取,完整保留状态及其后的讨论', async () => { - const { listCommentsForIssue } = await import('./issueLoader.js') - const comments = [ - comment(JSON.stringify({ column: 'in-progress' }), 1, 1), - ...Array.from({ length: 77 }, (_, i) => comment('later discussion', 1, i + 2)), - ] - listIssueComments.mockResolvedValue(comments) - - const out = await listCommentsForIssue({ - host: 'gitea.example', - token: 't', - owner: 'o', - repo: 'r', - index: 1, - commentCount: 78, - }) - - expect(listIssueComments).toHaveBeenCalledOnce() - expect(out).toEqual(comments) - expect(out.at(-1)?.body).toContain('later discussion') - expect(out[0]?.body).toContain('in-progress') - }) -}) - -describe('loadIssues', () => { - beforeEach(() => { - getCurrentUser.mockReset() - listIssuesByFilter.mockReset() - listIssueComments.mockReset() - getDependencies.mockReset() - getPullRequest.mockReset() - postIssueComment.mockReset() - getCurrentUser.mockResolvedValue({ login: 'me', id: 1 }) - listIssuesByFilter.mockResolvedValue([]) - listIssueComments.mockResolvedValue([]) - getDependencies.mockResolvedValue([]) - postIssueComment.mockResolvedValue({}) - }) - - it('不读取整仓评论,只读取指定工单的评论', async () => { - const { loadIssues } = await import('./issueLoader.js') - listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })]) - listIssueComments.mockResolvedValue([ - comment(JSON.stringify({ column: 'todo' }), 7, 1), - ]) - - const { issues, me } = await loadIssues({ - host: 'gitea.example', - token: 't', - owner: 'o', - repo: 'r', - scope: 'all', - }) - - expect(me).toBe('me') - expect(listIssueComments).toHaveBeenCalledWith(expect.objectContaining({ index: 7 })) - expect(issues).toHaveLength(1) - expect(issues[0].column).toBe('todo') - }) - - it('缺 state 评论时用默认列,不代别人发评论', async () => { - const { loadIssues } = await import('./issueLoader.js') - listIssuesByFilter.mockResolvedValue([issue(3, { comments: 0, user: { login: 'cruldra' } })]) - - const { issues } = await loadIssues({ - host: 'gitea.example', - token: 't', - owner: 'o', - repo: 'r', - scope: 'all', - }) - expect(issues[0].column).toBe('todo') - expect(postIssueComment).not.toHaveBeenCalled() - }) -}) - -describe('loadIssues 读状态评论', () => { - beforeEach(() => { - getCurrentUser.mockReset() - listIssuesByFilter.mockReset() - listIssueComments.mockReset() - getDependencies.mockReset() - getPullRequest.mockReset() - getCurrentUser.mockResolvedValue({ login: 'me', id: 1 }) - getDependencies.mockResolvedValue([]) - }) - - it('认隐藏格式,最新一条有效的状态胜出', async () => { - const { loadIssues } = await import('./issueLoader.js') - listIssuesByFilter.mockResolvedValue([issue(8, { comments: 3 })]) - listIssueComments.mockResolvedValue([ - comment('{"column":"todo"}', 8, 1), - comment('', 8, 2), - comment('普通讨论', 8, 3), - ]) - - const { issues } = await loadIssues({ host: 'gitea.example', token: 't', owner: 'o', repo: 'r', scope: 'all' }) - - expect(issues[0]).toMatchObject({ column: 'review', branch: 'feature/x', specFile: 'docs/a.md' }) - }) -}) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts deleted file mode 100644 index 91298ea..0000000 --- a/vscode/src/gitea/issueLoader.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Loads issues from the workspace's Gitea repository using the fetch-based - * `api.ts` client and resolves each issue's Kanban column from a JSON - * state-comment convention. - * - * Flow: - * 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。 - * 2. 每张工单一次读取全部评论,从中解析最新的 column state JSON。 - * 不拉整仓库评论。缺 state 时只在内存里用默认列,不发评论。 - * 3. 非 done 工单并行补依赖和 PR 状态。 - */ - -import type { GiteaComment, GiteaIssue } from './api' -import type { Issue, IssueColumn } from './types' -import * as fs from 'node:fs' -import { resolveWorktreePath } from '../git/worktree' -import { latestState } from '../issues/stateComment' -import { issueStateFields } from '../issues/stateFields' -import { - getCurrentUser, - getDependencies, - getIssue, - getPullRequest, - listIssueComments, - listIssuesByFilter, -} from './api' -import { resolveAvatars } from './avatars' - -function defaultColumnForState(state: string): IssueColumn { - return state === 'open' ? 'todo' : 'done' -} - -/** 工单当前状态:评论里最新一条状态评论,没有就是空对象。 */ -function stateOf(comments: GiteaComment[]): Record { - return latestState(comments.map(comment => comment.body)) ?? {} -} - -/** Merges issue lists by `number`, preferring the first occurrence. */ -function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] { - const map = new Map() - for (const list of lists) { - for (const issue of list) { - if (!map.has(issue.number)) - map.set(issue.number, issue) - } - } - return [...map.values()] -} - -const COMMENT_FETCH_CONCURRENCY = 8 - -async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const out: R[] = Array.from({ length: items.length }) - let next = 0 - async function worker(): Promise { - while (true) { - const i = next++ - if (i >= items.length) - return - out[i] = await fn(items[i]) - } - } - const n = Math.min(Math.max(limit, 1), items.length) - if (n === 0) - return out - await Promise.all(Array.from({ length: n }, () => worker())) - return out -} - -/** - * 按工单读取评论,用于解析看板状态。 - * commentCount=0 不发请求。 - */ -export async function listCommentsForIssue(opts: { - host: string - token: string - owner: string - repo: string - index: number - commentCount: number -}): Promise { - if (opts.commentCount <= 0) - return [] - return listIssueComments(opts) -} - -/** - * Builds an `Issue` from a Gitea issue plus its comment bucket. - * - * Encapsulates the per-issue parsing and side-effects (seeding a default - * state comment when none exists, statting the worktree path) so it can be - * reused by both the full loader and `loadSingleIssue`. - */ -/** - * Resolves the live merged state for a PR by querying gitea. Returns the - * boolean on success and `undefined` on any failure (network, 404, non-numeric - * index, etc.) so callers can fall back to whatever they had cached. - */ -/** - * Resolves the live PR status by querying gitea. Returns `{ merged, mergedAt }` - * on success and `{}` on any failure (no pr, non-numeric index, network, 404, - * parse error) so callers can fall back to whatever they had cached. `mergedAt` - * is the PR's `merged_at` (ISO 8601) and powers the 完成 列's merge-time sort. - */ -async function fetchPrStatus(opts: { - host: string - token: string - owner: string - repo: string - pr: string | undefined -}): Promise<{ merged?: boolean, mergedAt?: string }> { - const { host, token, owner, repo, pr } = opts - if (!pr) - return {} - const index = Number.parseInt(pr, 10) - if (!Number.isFinite(index) || index <= 0) - return {} - try { - const data = await getPullRequest({ host, token, owner, repo, index }) - return { merged: data.merged === true, mergedAt: data.merged_at ?? undefined } - } - catch { - return {} - } -} - -async function buildIssue(opts: { - host: string - token: string - owner: string - repo: string - workspaceRoot?: string - issue: GiteaIssue - comments: GiteaComment[] - prerequisite: number | undefined - /** 实时 PR merged 状态(由 loader 并发查询后传入);undefined 表示查询失败或无 PR,回退 state JSON。 */ - liveMerged?: boolean - /** PR 的 merged_at(来自 PR API),仅用于完成列排序;查询失败/无 PR 为 undefined。 */ - liveMergedAt?: string -}): Promise { - const { owner, repo, workspaceRoot, issue, comments, prerequisite, liveMerged, liveMergedAt } = opts - const id = `${owner}/${repo}#${issue.number}` - const { - column: fromComment, - prMerged: persistedMerged, - prMergedAt: persistedMergedAt, - ...stateFields - } = issueStateFields(stateOf(comments)) - // 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。 - const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged - // 完成列排序用的 merged_at:实时查询优先,否则用持久化值(完成工单不再实时拉 PR)。 - const mergedAt = liveMergedAt ?? persistedMergedAt - // 缺状态时只在本机按工单开/关给默认列,不在加载时补评论:谁的看板先刷到,评论就挂在谁名下。 - const column = fromComment ?? defaultColumnForState(issue.state) - - // Synchronously stat the worktree path so the UI can render it as a - // live link vs. plain text. A single existsSync per issue is cheap and - // avoids racing with the rest of the async pipeline. - let worktreeExists: boolean | undefined - if (stateFields.worktreePath && workspaceRoot) { - try { - worktreeExists = fs.existsSync(resolveWorktreePath(stateFields.worktreePath, workspaceRoot)) - } - catch { - worktreeExists = false - } - } - - return { - id, - number: issue.number, - title: issue.title, - column, - ...stateFields, - ...(prMerged !== undefined ? { prMerged } : {}), - ...(mergedAt ? { prMergedAt: mergedAt } : {}), - ...(issue.closed_at ? { closedAt: issue.closed_at } : {}), - ...(worktreeExists !== undefined ? { worktreeExists } : {}), - ...(prerequisite !== undefined ? { prerequisite } : {}), - assignees: (issue.assignees ?? []).map(a => a.login), - ...(issue.user?.login ? { poster: issue.user.login } : {}), - htmlUrl: issue.html_url, - } -} - -export async function loadIssues(opts: { - host: string - token: string - owner: string - repo: string - /** Absolute workspace root used to resolve `worktreeExists` against disk. */ - workspaceRoot?: string - /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ - scope?: 'mine' | 'all' -}): Promise<{ issues: Issue[], me: string, avatars: Record }> { - const { host, token, owner, repo, workspaceRoot } = opts - - // ① 工单列表 + 当前用户 - let merged: Awaited> - let me: string - if (opts.scope === 'all') { - const [all, user] = await Promise.all([ - listIssuesByFilter({ host, token, owner, repo }), - getCurrentUser({ host, token }), - ]) - merged = all - me = user.login - } - else { - const user = await getCurrentUser({ host, token }) - me = user.login - const [assigned, created] = await Promise.all([ - listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }), - listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }), - ]) - merged = mergeIssues(assigned, created) - } - - // ② 每张工单读取一次评论,再从中解析 column - const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue => - listCommentsForIssue({ - host, - token, - owner, - repo, - index: issue.number, - commentCount: issue.comments, - })) - const buckets = new Map() - for (let i = 0; i < merged.length; i++) - buckets.set(merged[i].number, commentLists[i]) - - // 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。 - // 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并), - // 跳过它们的 getDependencies + getPullRequest 往返是面板加载提速的核心: - // 完成列动辄上百工单,原先每个都发 2 次远程请求。 - // column 计算与 buildIssue 的判断保持一致:state comment 优先,缺失时按 issue.state 默认。 - const columns: IssueColumn[] = merged.map((issue) => { - const bucket = buckets.get(issue.number) ?? [] - return issueStateFields(stateOf(bucket)).column ?? defaultColumnForState(issue.state) - }) - - // Concurrently fetch each issue's dependencies + live PR merged state. - // Per product rule we keep at most one prerequisite — the first entry in - // the array (Gitea orders by creation time). Failures (412 dependencies - // disabled, network, etc.) are silently swallowed so one bad issue can't - // take down the whole loader. PR merged 查询同样以失败兜底 undefined。 - // done 工单两者都跳过:prerequisite 直接 undefined,PR 状态由 state JSON 兜底。 - const [prerequisites, liveMergedList] = await Promise.all([ - Promise.all(merged.map(async (issue, i) => { - if (columns[i] === 'done') - return undefined - try { - const deps = await getDependencies({ host, token, owner, repo, index: issue.number }) - return deps.length > 0 ? deps[0].number : undefined - } - catch { - return undefined - } - })), - Promise.all(merged.map(async (issue, i) => { - if (columns[i] === 'done') - return {} - const bucket = buckets.get(issue.number) ?? [] - const { pr } = issueStateFields(stateOf(bucket)) - return fetchPrStatus({ host, token, owner, repo, pr }) - })), - ]) - - const resolved: Issue[] = [] - for (let i = 0; i < merged.length; i++) { - const issue = merged[i] - const prerequisite = prerequisites[i] - const { merged: liveMerged, mergedAt: liveMergedAt } = liveMergedList[i] ?? {} - const bucket = buckets.get(issue.number) ?? [] - resolved.push(await buildIssue({ - host, - token, - owner, - repo, - workspaceRoot, - issue, - comments: bucket, - prerequisite, - liveMerged, - liveMergedAt, - })) - } - - // 团队视图下卡片按人贴头像;抓不到头像不算失败,前端回退首字母。 - const avatars = await resolveAvatars( - merged.flatMap(issue => [ - ...(issue.assignees ?? []).map(a => [a.login, a.avatar_url] as const), - ...(issue.user ? [[issue.user.login, issue.user.avatar_url] as const] : []), - ]), - token, - ) - - return { issues: resolved, me, avatars } -} - -/** - * Loads a single issue by number, resolving its column / state metadata the - * same way `loadIssues` does. Returns `null` if Gitea reports the issue - * does not exist (404). Used by the incremental "issue created" path so the - * webview can append one card without rerendering the whole board. - */ -export async function loadSingleIssue(opts: { - host: string - token: string - owner: string - repo: string - workspaceRoot?: string - issueNumber: number -}): Promise { - const { host, token, owner, repo, workspaceRoot, issueNumber } = opts - - const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) - if (!issue) - return null - - const [comments, prerequisite] = await Promise.all([ - listIssueComments({ host, token, owner, repo, index: issueNumber }), - (async () => { - try { - const deps = await getDependencies({ host, token, owner, repo, index: issueNumber }) - return deps.length > 0 ? deps[0].number : undefined - } - catch { - return undefined - } - })(), - ]) - - // 解析 state JSON 拿到 pr 字段后实时查 merged 状态;失败 fallback undefined。 - const { pr } = issueStateFields(stateOf(comments)) - const { merged: liveMerged, mergedAt: liveMergedAt } = await fetchPrStatus({ host, token, owner, repo, pr }) - - return buildIssue({ - host, - token, - owner, - repo, - workspaceRoot, - issue, - comments, - prerequisite, - liveMerged, - liveMergedAt, - }) -} diff --git a/vscode/src/gitea/repoContext.ts b/vscode/src/gitea/repoContext.ts deleted file mode 100644 index 81198ca..0000000 --- a/vscode/src/gitea/repoContext.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ExtensionContext } from 'vscode' -import { workspace } from 'vscode' -import { getToken } from '../auth/secrets' -import { detectRepo } from '../git/remote' - -/** 当前工作区(第一个工作区文件夹)对应的 Gitea 仓库和 token。 */ -export interface RepoContext { - workspaceRoot: string - host: string - owner: string - repo: string - token: string -} - -/** - * 解析当前工作区的 Gitea 仓库与 token。没打开工作区、origin 不是 Gitea 仓库、没配 token - * 任一情况返回 undefined,调用方据此走空态/错误分支。 - */ -export async function resolveRepoContext(context: ExtensionContext): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - return undefined - const remote = await detectRepo(workspaceRoot) - if (!remote) - return undefined - const token = await getToken(context, remote.host) - if (!token) - return undefined - return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token } -} diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts deleted file mode 100644 index ab8c8bc..0000000 --- a/vscode/src/gitea/stateJson.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Gitea 工单上的状态评论读写;格式与合并规则见 [状态评论](vscode/src/issues/stateComment.ts)。 - */ - -import type { StateCommentThread } from '../issues/stateComment' -import { mergeState, mergeStateGuarded, readState } from '../issues/stateComment' -import { listIssueComments, postIssueComment } from './api' - -export interface ReadStateJsonCommentOpts { - host: string - owner: string - repo: string - token: string - issueNumber: number -} - -export interface MergeStateJsonCommentOpts extends ReadStateJsonCommentOpts { - extra: Record - defaults?: Record -} - -export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts { - protectDoneColumn?: boolean -} - -function giteaThread(opts: ReadStateJsonCommentOpts): StateCommentThread { - const where = { host: opts.host, token: opts.token, owner: opts.owner, repo: opts.repo, index: opts.issueNumber } - return { - bodies: async () => (await listIssueComments(where)).map(comment => comment.body), - post: body => postIssueComment({ ...where, body }), - } -} - -/** 把 `extra` 合并进工单状态并写一条新的状态评论;网络或接口失败直接抛出。 */ -export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise { - await mergeState(giteaThread(opts), opts.extra, opts.defaults) -} - -/** 同 [mergeStateJsonComment],但可以保护完成列不被自动流程改掉。 */ -export function mergeStateJsonCommentGuarded( - opts: MergeStateJsonCommentGuardedOpts, -): Promise<{ posted: boolean, protectedDoneColumn: boolean }> { - return mergeStateGuarded(giteaThread(opts), opts.extra, { protectDoneColumn: opts.protectDoneColumn, defaults: opts.defaults }) -} - -/** 读工单当前状态;没有状态评论时是空对象,网络或接口失败直接抛出。 */ -export function readStateJsonComment(opts: ReadStateJsonCommentOpts): Promise> { - return readState(giteaThread(opts)) -} diff --git a/vscode/src/issues/localState.ts b/vscode/src/issues/localState.ts index 26e9705..90fe9e4 100644 --- a/vscode/src/issues/localState.ts +++ b/vscode/src/issues/localState.ts @@ -8,7 +8,7 @@ */ import type { ExtensionContext } from 'vscode' -import type { Issue } from '../gitea/types' +import type { Issue } from './types' import * as fs from 'node:fs' import { resolveWorktreePath } from '../git/worktree' diff --git a/vscode/src/issues/stateFields.ts b/vscode/src/issues/stateFields.ts index ff468e9..18f1a38 100644 --- a/vscode/src/issues/stateFields.ts +++ b/vscode/src/issues/stateFields.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueColumn } from '../gitea/types' +import type { Issue, IssueColumn } from './types' const COLUMNS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] diff --git a/vscode/src/issues/stateRouter.ts b/vscode/src/issues/stateRouter.ts index aebac3e..11b7987 100644 --- a/vscode/src/issues/stateRouter.ts +++ b/vscode/src/issues/stateRouter.ts @@ -1,22 +1,14 @@ /** - * Source-aware workflow-state persistence. + * 按工单来源读写工作流状态。 * - * Gitea and YouTrack cards share the same board and the same session/worktree - * machinery, but their workflow state (column, sessionId, worktreePath, …) - * lives in different places: a gitea issue comment vs a YouTrack issue comment. - * Handlers call these helpers with an `IssueRef` instead of hard-coding the - * gitea backend, so the same handler body serves both sources. - * - * The gitea branch reproduces exactly what the handlers did inline before, so - * gitea behavior is unchanged. + * 代码托管仓库和 YouTrack 的卡片共用一块看板和同一套会话、worktree 机制,但状态(列、会话 id、worktree 路径等) + * 存在各自工单的评论里。handler 拿 [IssueRef] 调这里,不用关心工单来自哪边。 */ import type { ExtensionContext } from 'vscode' -import { workspace } from 'vscode' -import { getToken, getYouTrackToken } from '../auth/secrets' -import { detectRepo } from '../git/remote' -import { closeIssue as giteaCloseIssue } from '../gitea/api' -import { mergeStateJsonComment, readStateJsonComment } from '../gitea/stateJson' +import { getYouTrackToken } from '../auth/secrets' +import { requireForge } from '../forge/context' +import { mergeForgeState, readForgeState } from '../forge/state' import { getSettings } from '../settings/store' import { applyCommand, resolvedStateCommand } from '../youtrack/api' import { youtrackHost } from '../youtrack/issueLoader' @@ -36,19 +28,6 @@ function isYouTrack(ref: IssueRef): ref is IssueRef & { externalId: string } { return ref.source === 'youtrack' && typeof ref.externalId === 'string' && ref.externalId.length > 0 } -async function giteaDeps(ctx: ExtensionContext): Promise<{ host: string, owner: string, repo: string, token: string }> { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - throw new Error('未打开工作区文件夹') - const remote = await detectRepo(workspaceRoot) - if (!remote) - throw new Error('当前工作区没有 Gitea 远程仓库') - const token = await getToken(ctx, remote.host) - if (!token) - throw new Error('请先完成 Gitea 配置') - return { host: remote.host, owner: remote.owner, repo: remote.repo, token } -} - async function youtrackAuth(ctx: ExtensionContext): Promise<{ baseUrl: string, token: string }> { const baseUrl = getSettings(ctx).youtrackBaseUrl.trim() if (!baseUrl) @@ -71,8 +50,8 @@ export async function readIssueState(ctx: ExtensionContext, ref: IssueRef): Prom base = await readStateComment(auth, ref.externalId) } else { - const d = await giteaDeps(ctx) - base = await readStateJsonComment({ ...d, issueNumber: ref.number }) + const forge = await requireForge(ctx) + base = await readForgeState(forge.provider, ref.number) } return { ...base, ...getLocalIssueState(ctx, ref.source, ref.number) } } @@ -94,12 +73,12 @@ export async function mergeIssueState(ctx: ExtensionContext, ref: IssueRef, extr await mergeStateComment(auth, ref.externalId, shared) return } - const d = await giteaDeps(ctx) - await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra: shared }) + const forge = await requireForge(ctx) + await mergeForgeState(forge.provider, ref.number, shared) } /** - * Resolve/close an issue in its tracker. Gitea: PATCH state=closed. YouTrack: + * Resolve/close an issue in its tracker. Forge: close the issue. YouTrack: * apply the configured close command, else auto-detect the project's first * `isResolved` state value. Returns false (with no throw) when a YouTrack close * command can't be determined, so callers can surface a hint. @@ -114,7 +93,7 @@ export async function closeIssueByRef(ctx: ExtensionContext, ref: IssueRef): Pro await applyCommand(auth, ref.externalId, command) return true } - const d = await giteaDeps(ctx) - await giteaCloseIssue({ ...d, issueNumber: ref.number }) + const forge = await requireForge(ctx) + await forge.provider.closeIssue(ref.number) return true } diff --git a/vscode/src/gitea/types.ts b/vscode/src/issues/types.ts similarity index 100% rename from vscode/src/gitea/types.ts rename to vscode/src/issues/types.ts diff --git a/vscode/src/panel/KanbanPanel.cache.test.ts b/vscode/src/panel/KanbanPanel.cache.test.ts index 566ceff..1bc5c08 100644 --- a/vscode/src/panel/KanbanPanel.cache.test.ts +++ b/vscode/src/panel/KanbanPanel.cache.test.ts @@ -1,5 +1,5 @@ import type { ExtensionContext, Terminal, WebviewPanel } from 'vscode' -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import type { WebhookEvent } from '../webhook/eventParser' import type { KanbanSnapshot } from './kanbanSnapshot' import type { ExtensionToWebview, WebviewToExtension } from './messages' @@ -42,8 +42,7 @@ vi.mock('../cc/codexSessionWatcher', () => ({ watchForCodexSession: mocks.watchS vi.mock('../auth/secrets', () => ({ getToken: async () => 'token', deleteToken: vi.fn() })) vi.mock('../figma/importPlugin', () => ({})) vi.mock('../git/remote', () => ({ detectRepo: mocks.detect })) -vi.mock('../gitea/api', () => ({ GiteaApiError: class extends Error {} })) -vi.mock('../gitea/issueLoader', () => ({ loadIssues: mocks.load, loadSingleIssue: mocks.loadSingle })) +vi.mock('../forge/issueLoader', () => ({ loadIssues: mocks.load, loadSingleIssue: mocks.loadSingle })) vi.mock('../issues/localState', async importOriginal => ({ ...await importOriginal(), overlayLocalIssueState: (_ctx: unknown, issues: Issue[]) => issues, diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index a78367b..d70df60 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -5,8 +5,8 @@ import type { WebviewPanel, } from 'vscode' import type { HookContext } from '../git/worktreeHooks' -import type { Issue } from '../gitea/types' import type { IssueRef } from '../issues/stateRouter' +import type { Issue } from '../issues/types' import type { IssueUpdate } from '../shared/kanbanUpdates' import type { SessionTabKind } from './handlers/terminals' import type { IssueOwnershipFacts } from './issueOwnership' @@ -14,13 +14,11 @@ import type { KanbanScope } from './kanbanScope' import type { BoardIssues } from './kanbanSnapshot' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode' -import { deleteToken, getToken } from '../auth/secrets' +import { deleteToken } from '../auth/secrets' import { importPluginIntoFigma } from '../figma/importPlugin' -import { detectRepo } from '../git/remote' -import { - GiteaApiError, -} from '../gitea/api' -import { loadIssues } from '../gitea/issueLoader' +import { resolveForge } from '../forge/context' +import { loadIssues } from '../forge/issueLoader' +import { ForgeApiError } from '../forge/types' import { overlayLocalIssueState } from '../issues/localState' import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter' import { logger } from '../logging/logger' @@ -744,7 +742,7 @@ export class KanbanWebviewPanel { * sessions reuse the same tone. * * Always returns a ThemeColor (never undefined) — caller is expected to - * only call this once it has a usable workspace + Gitea remote + token. + * only call this once it has a usable workspace + forge remote + credentials. * Persistence failures are logged and swallowed. */ // internal: handler 模块访问 @@ -757,8 +755,8 @@ export class KanbanWebviewPanel { themeColor: new ThemeColor(id), iconUri: themeColorIdToIconUri(id), }) - // Deterministic fallback when we can't talk to gitea (no workspace, no - // remote, no token). Matches what `issueTerminalColor(issueNumber)` would + // Deterministic fallback when we can't talk to the forge (no workspace, no + // remote, no credentials). Matches what `issueTerminalColor(issueNumber)` would // pick so the icon is still stable across sessions. const fallbackId = PALETTE[((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length] @@ -772,14 +770,7 @@ export class KanbanWebviewPanel { stored = presetState.color } else { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - return pack(fallbackId) - const remote = await detectRepo(workspaceRoot) - if (!remote) - return pack(fallbackId) - const token = await getToken(this.context, remote.host) - if (!token) + if (!(await resolveForge(this.context)).ok) return pack(fallbackId) try { @@ -1019,78 +1010,24 @@ export class KanbanWebviewPanel { } private async loadAndPushInner(load: KanbanLoad): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - // eslint-disable-next-line no-console - console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot) - if (!workspaceRoot) { - this.postMessage({ - type: 'issues/error', - message: '请先打开一个工作区文件夹', - }) - return - } - - const remote = await detectRepo(workspaceRoot) + const resolved = await resolveForge(this.context) if (this.activeLoad !== load || load.invalidated) return - if (!remote) { - this.postMessage({ - type: 'issues/error', - message: '当前工作区没有 Gitea 远程仓库', - }) - return - } - - const { host, owner, repo } = remote - const token = await getToken(this.context, host) - if (this.activeLoad !== load || load.invalidated) - return - if (!token) { - const s = getSettings(this.context) - this.postMessage({ - type: 'settings/show', - host, - tokenSaved: false, - webhookPort: s.webhookPort, - webhookSecret: s.webhookSecret, - brainstormPrompt: s.brainstormPrompt, - implementPlanPrompt: s.implementPlanPrompt, - autoReview: s.autoReview, - reviewPrompt: s.reviewPrompt, - testPrompt: s.testPrompt, - prototypePrompt: s.prototypePrompt, - devBranch: s.devBranch, - releaseBranchPrefix: s.releaseBranchPrefix, - jenkinsJob: s.jenkinsJob, - jenkinsProdHost: s.jenkinsProdHost, - worktreeDirectory: s.worktreeDirectory, - worktreeOpenWith: s.worktreeOpenWith, - pycharmCommand: s.pycharmCommand, - worktreePostCreateScript: s.worktreePostCreateScript, - worktreePreRemoveScript: s.worktreePreRemoveScript, - implTabPreCreateScript: s.implTabPreCreateScript, - implTabPostCloseScript: s.implTabPostCloseScript, - splitNewTerminals: s.splitNewTerminals, - defaultBrainstormProfilePath: s.defaultBrainstormProfilePath, - defaultImplementProfilePath: s.defaultImplementProfilePath, - defaultTestProfilePath: s.defaultTestProfilePath, - defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, - defaultAskProfilePath: s.defaultAskProfilePath, - defaultRewriteProfilePath: s.defaultRewriteProfilePath, - conflictResolutionProfilePath: s.conflictResolutionProfilePath, - commitProfilePath: await getEffectiveCommitProfilePath(this.context), - prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context), - codexModel: s.codexModel, - codexReasoningEffort: s.codexReasoningEffort, - profilesDirectory: s.profilesDirectory, - }) + if (!resolved.ok) { + // 只有 Gitea 的 token 存在插件设置里,缺了就弹设置让用户填;别的原因直接显示。 + if (resolved.reason === 'no-credentials' && resolved.kind === 'gitea') + await this.showTokenSettings(resolved.host) + else + this.postMessage({ type: 'issues/error', message: resolved.message }) return } + const { forge } = resolved + const { workspaceRoot, host, owner, repo } = forge const scope = this.context.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE try { const [{ issues: giteaIssues, me, avatars }, youtrackList] = await Promise.all([ - loadIssues({ host, token, owner, repo, workspaceRoot, scope }), + loadIssues(forge, scope), this.loadYouTrackSafe(), ]) if (this.activeLoad !== load || load.invalidated) @@ -1138,47 +1075,9 @@ export class KanbanWebviewPanel { catch (err) { if (this.activeLoad !== load || load.invalidated) return - if (err instanceof GiteaApiError && err.status === 401) { + if (err instanceof ForgeApiError && err.status === 401 && forge.kind === 'gitea') { await deleteToken(this.context, host) - const s = getSettings(this.context) - this.postMessage({ - type: 'settings/show', - host, - errorMessage: 'Token 无效或已过期,请重新填写', - tokenSaved: false, - webhookPort: s.webhookPort, - webhookSecret: s.webhookSecret, - brainstormPrompt: s.brainstormPrompt, - implementPlanPrompt: s.implementPlanPrompt, - autoReview: s.autoReview, - reviewPrompt: s.reviewPrompt, - testPrompt: s.testPrompt, - prototypePrompt: s.prototypePrompt, - devBranch: s.devBranch, - releaseBranchPrefix: s.releaseBranchPrefix, - jenkinsJob: s.jenkinsJob, - jenkinsProdHost: s.jenkinsProdHost, - worktreeDirectory: s.worktreeDirectory, - worktreeOpenWith: s.worktreeOpenWith, - pycharmCommand: s.pycharmCommand, - worktreePostCreateScript: s.worktreePostCreateScript, - worktreePreRemoveScript: s.worktreePreRemoveScript, - implTabPreCreateScript: s.implTabPreCreateScript, - implTabPostCloseScript: s.implTabPostCloseScript, - splitNewTerminals: s.splitNewTerminals, - defaultBrainstormProfilePath: s.defaultBrainstormProfilePath, - defaultImplementProfilePath: s.defaultImplementProfilePath, - defaultTestProfilePath: s.defaultTestProfilePath, - defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, - defaultAskProfilePath: s.defaultAskProfilePath, - defaultRewriteProfilePath: s.defaultRewriteProfilePath, - conflictResolutionProfilePath: s.conflictResolutionProfilePath, - commitProfilePath: await getEffectiveCommitProfilePath(this.context), - prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context), - codexModel: s.codexModel, - codexReasoningEffort: s.codexReasoningEffort, - profilesDirectory: s.profilesDirectory, - }) + await this.showTokenSettings(host, 'Token 无效或已过期,请重新填写') return } const baseMessage = err instanceof Error ? err.message : String(err) @@ -1187,6 +1086,49 @@ export class KanbanWebviewPanel { } } + /** 弹出设置让用户填 `host` 的 Gitea token;`errorMessage` 说明为什么要重填。 */ + private async showTokenSettings(host: string, errorMessage?: string): Promise { + const s = getSettings(this.context) + this.postMessage({ + type: 'settings/show', + host, + ...(errorMessage ? { errorMessage } : {}), + tokenSaved: false, + webhookPort: s.webhookPort, + webhookSecret: s.webhookSecret, + brainstormPrompt: s.brainstormPrompt, + implementPlanPrompt: s.implementPlanPrompt, + autoReview: s.autoReview, + reviewPrompt: s.reviewPrompt, + testPrompt: s.testPrompt, + prototypePrompt: s.prototypePrompt, + devBranch: s.devBranch, + releaseBranchPrefix: s.releaseBranchPrefix, + jenkinsJob: s.jenkinsJob, + jenkinsProdHost: s.jenkinsProdHost, + worktreeDirectory: s.worktreeDirectory, + worktreeOpenWith: s.worktreeOpenWith, + pycharmCommand: s.pycharmCommand, + worktreePostCreateScript: s.worktreePostCreateScript, + worktreePreRemoveScript: s.worktreePreRemoveScript, + implTabPreCreateScript: s.implTabPreCreateScript, + implTabPostCloseScript: s.implTabPostCloseScript, + splitNewTerminals: s.splitNewTerminals, + defaultBrainstormProfilePath: s.defaultBrainstormProfilePath, + defaultImplementProfilePath: s.defaultImplementProfilePath, + defaultTestProfilePath: s.defaultTestProfilePath, + defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, + defaultAskProfilePath: s.defaultAskProfilePath, + defaultRewriteProfilePath: s.defaultRewriteProfilePath, + conflictResolutionProfilePath: s.conflictResolutionProfilePath, + commitProfilePath: await getEffectiveCommitProfilePath(this.context), + prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context), + codexModel: s.codexModel, + codexReasoningEffort: s.codexReasoningEffort, + profilesDirectory: s.profilesDirectory, + }) + } + /** * Resolve an issue number to its source identity, for routing state * persistence. Unknown numbers default to gitea (back-compat / pre-load). diff --git a/vscode/src/panel/handlers/branchCleanup.ts b/vscode/src/panel/handlers/branchCleanup.ts index f87cb01..4fff31c 100644 --- a/vscode/src/panel/handlers/branchCleanup.ts +++ b/vscode/src/panel/handlers/branchCleanup.ts @@ -1,5 +1,5 @@ +import type { ForgeContext } from '../../forge/context' import { deleteLocalBranch } from '../../git/branches' -import { deleteBranch } from '../../gitea/api' import { logger } from '../../logging/logger' /** @@ -20,11 +20,10 @@ export async function cleanupFeatureBranch(opts: { branch: string issueNumber: number devBranch?: string - /** 缺 remote 或 token 时跳过远程删除,只删本地。 */ - remote?: { host: string, owner: string, repo: string } - token?: string + /** 没有代码托管仓库时跳过远程删除,只删本地。 */ + forge?: ForgeContext }): Promise { - const { workspaceRoot, branch, issueNumber, devBranch, remote, token } = opts + const { workspaceRoot, branch, issueNumber, devBranch, forge } = opts if (!branch) return @@ -36,9 +35,9 @@ export async function cleanupFeatureBranch(opts: { return } - if (remote && token) { + if (forge) { try { - await deleteBranch({ host: remote.host, token, owner: remote.owner, repo: remote.repo, branch }) + await forge.provider.deleteBranch(branch) logger.add({ level: 'info', source: 'panel', message: `已删除远程 feature 分支 ${branch} (issue #${issueNumber})` }) } catch (err) { diff --git a/vscode/src/panel/handlers/forgeGate.ts b/vscode/src/panel/handlers/forgeGate.ts new file mode 100644 index 0000000..390147f --- /dev/null +++ b/vscode/src/panel/handlers/forgeGate.ts @@ -0,0 +1,23 @@ +import type { ForgeContext } from '../../forge/context' +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { window } from 'vscode' +import { resolveForge } from '../../forge/context' +import { makeNonce } from '../../webview/html' + +/** 解析当前工作区的代码托管仓库;解析不出来就弹 toast 说明原因,返回 `undefined`。 */ +export async function forgeOrToast(panel: KanbanWebviewPanel): Promise { + const resolved = await resolveForge(panel.context) + if (resolved.ok) + return resolved.forge + panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: resolved.message, dismissOnTimer: 5000 }) + return undefined +} + +/** 同 [forgeOrToast],但用 VS Code 的错误弹窗说明原因,给从命令或会话入口进来、不一定看得到看板的操作用。 */ +export async function forgeOrErrorMessage(panel: KanbanWebviewPanel): Promise { + const resolved = await resolveForge(panel.context) + if (resolved.ok) + return resolved.forge + void window.showErrorMessage(resolved.message) + return undefined +} diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 8a7fb25..97717c8 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -1,5 +1,5 @@ import type { HandoffSessions } from '../../cc/handoffManifest' -import type { IssueColumn } from '../../gitea/types' +import type { IssueColumn } from '../../issues/types' import { formatByteSize } from '../../cc/handoffArchive' import { LOCAL_PROFILE_FIELDS, LOCAL_STATE_FIELDS } from '../../issues/localState' diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 4ba98d3..3c14f7e 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -1,5 +1,6 @@ import type { HandoffManifest, HandoffSessions } from '../../cc/handoffManifest' -import type { IssueColumn } from '../../gitea/types' +import type { ForgeContext } from '../../forge/context' +import type { IssueColumn } from '../../issues/types' import type { HandoffStore } from '../../oss/handoffStore' import type { KanbanWebviewPanel } from '../KanbanPanel' import { execFile } from 'node:child_process' @@ -7,18 +8,15 @@ import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { window, workspace } from 'vscode' -import { loginForToken } from '../../auth/identity' -import { getToken } from '../../auth/secrets' +import { window } from 'vscode' +import { loginFor } from '../../auth/identity' import { formatByteSize, packHandoff, unpackHandoff } from '../../cc/handoffArchive' import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle' import { HANDOFF_MANIFEST_FILE, handoffBundleKey, parseHandoffManifest } from '../../cc/handoffManifest' import { claudeProjectsRoot } from '../../cc/sessionBundle' import { projectsDirFor } from '../../cc/sessionWatcher' -import { detectRepo } from '../../git/remote' import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath' -import { getIssue, listRepoAssignees, updateIssueAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' import { deleteHandoffBundle, downloadHandoffBundle, uploadHandoffBundle } from '../../oss/handoffStore' import { HANDOFF_STORE_MISSING_MESSAGE, readHandoffStore } from '../../oss/handoffStoreSetup' @@ -26,6 +24,7 @@ import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessio import { getSettings } from '../../settings/store' import { makeNonce } from '../../webview/html' import { cleanupFeatureBranch } from './branchCleanup' +import { forgeOrToast } from './forgeGate' import { canAcceptHandoff, canStartHandoff, @@ -39,12 +38,8 @@ import { } from './handoff' import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest' -export interface RepoCtx { - workspaceRoot: string - host: string - owner: string - repo: string - token: string +/** 当前工作区的代码托管仓库,加上本机凭据对应的 login。 */ +export interface RepoCtx extends ForgeContext { me: string } @@ -59,27 +54,15 @@ export function dismiss(panel: KanbanWebviewPanel, id: string): void { } export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - toast(panel, 'error', '请先打开一个工作区文件夹') + const forge = await forgeOrToast(panel) + if (!forge) return undefined - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - toast(panel, 'error', '当前工作区没有 Gitea 远程仓库') - return undefined - } - const token = await getToken(panel.context, remote.host) - if (!token) { - toast(panel, 'error', '请先完成 Gitea 配置') - return undefined - } - const me = await loginForToken(remote.host, token) + const me = await loginFor(forge) if (!me) { - toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?') + toast(panel, 'error', '无法解析当前身份,token 是否失效?') return undefined } - return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me } + return { ...forge, me } } function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> { @@ -135,7 +118,7 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: return } try { - const users = await listRepoAssignees(ctx) + const users = await ctx.provider.listAssignees() panel.postMessage({ type: 'handoff/users-result', issueNumber, @@ -172,7 +155,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const ctx = await resolveRepoCtx(panel) if (!ctx) return { ok: false, message: '无法开始移交' } - const { workspaceRoot, host, owner, repo, token, me } = ctx + const { workspaceRoot, owner, repo, provider, me } = ctx let state: Record try { @@ -186,7 +169,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: // 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到 // assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。 - const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) + const issue = await provider.getIssue(issueNumber) if (!issue) { toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) return { ok: false, message: `工单 #${issueNumber} 不存在或已删除` } @@ -201,7 +184,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: // 避免重复移交或抢别人的工单。 const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' - const assignees = (issue.assignees ?? []).map(a => a.login) + const assignees = issue.assignees.map(a => a.login) if (!canStartHandoff({ source: 'gitea', column, handoffBundleKey: str(state.handoffBundleKey), assignees }, me)) { const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交` toast(panel, 'error', message) @@ -325,7 +308,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: } say('指派') try { - await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] }) + await provider.setIssueAssignees(issueNumber, [to]) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -406,7 +389,7 @@ async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Pro toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) return } - const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber }) + const issue = await ctx.provider.getIssue(issueNumber) if (!issue) { toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) return @@ -420,7 +403,7 @@ async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Pro column, ...(str(state.handoffBundleKey) ? { handoffBundleKey: str(state.handoffBundleKey) } : {}), ...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}), - assignees: (issue.assignees ?? []).map(a => a.login), + assignees: issue.assignees.map(a => a.login), }, } } @@ -497,7 +480,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber finish() return } - const { workspaceRoot, host, owner, repo, token, me } = ctx + const { workspaceRoot, provider, me } = ctx let state: Record try { @@ -525,13 +508,13 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber // 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被 // 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。 - const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) + const issue = await provider.getIssue(issueNumber) if (!issue) { progress.dismiss() toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) return } - if (!canAcceptHandoff({ handoffBundleKey: bundleKey, assignees: (issue.assignees ?? []).map(a => a.login) }, me)) { + if (!canAcceptHandoff({ handoffBundleKey: bundleKey, assignees: issue.assignees.map(a => a.login) }, me)) { progress.dismiss() toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`) return diff --git a/vscode/src/panel/handlers/issueChat.ts b/vscode/src/panel/handlers/issueChat.ts index 5298db7..4f55c74 100644 --- a/vscode/src/panel/handlers/issueChat.ts +++ b/vscode/src/panel/handlers/issueChat.ts @@ -1,7 +1,6 @@ -import type { GiteaComment } from '../../gitea/api' +import type { ForgeComment } from '../../forge/types' import type { KanbanWebviewPanel } from '../KanbanPanel' -import { listIssueComments, postIssueComment } from '../../gitea/api' -import { systemCommentText } from '../../gitea/issueComment' +import { systemCommentText } from '../../forge/issueComment' import { logger } from '../../logging/logger' import { resolveRepoCtx, toast } from './handoffFlow' @@ -15,14 +14,14 @@ export interface IssueChatComment { systemText?: string } -function toChat(comment: GiteaComment): IssueChatComment { +function toChat(comment: ForgeComment): IssueChatComment { const systemText = systemCommentText(comment.body) return { id: comment.id, - author: comment.user?.login ?? '未知', - authorName: comment.user?.full_name || undefined, - avatarUrl: comment.user?.avatar_url || undefined, - createdAt: comment.created_at, + author: comment.author?.login || '未知', + authorName: comment.author?.fullName, + avatarUrl: comment.author?.avatarUrl, + createdAt: comment.createdAt, body: comment.body, ...(systemText ? { systemText } : {}), } @@ -32,13 +31,7 @@ async function pushComments(panel: KanbanWebviewPanel, issueNumber: number): Pro const ctx = await resolveRepoCtx(panel) if (!ctx) return - const comments = await listIssueComments({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - index: issueNumber, - }) + const comments = await ctx.provider.listIssueComments(issueNumber) panel.postMessage({ type: 'issue/comments-result', issueNumber, @@ -65,14 +58,7 @@ export async function handleIssueCommentPost(panel: KanbanWebviewPanel, issueNum if (!ctx) return try { - await postIssueComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - index: issueNumber, - body: text, - }) + await ctx.provider.postIssueComment(issueNumber, text) await pushComments(panel, issueNumber) } catch (err) { diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index fa3a245..7b62924 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -1,4 +1,6 @@ -import type { IssueColumn } from '../../gitea/types' +import type { ForgeContext } from '../../forge/context' +import type { ForgePullRequest, MergeOutcome } from '../../forge/types' +import type { IssueColumn } from '../../issues/types' import type { LockedReason } from '../issueLock' import type { KanbanWebviewPanel } from '../KanbanPanel' import type { SessionTabKind } from './terminals' @@ -9,7 +11,6 @@ import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode' -import { getToken } from '../../auth/secrets' import { buildCcArgv } from '../../cc/ccCommand' import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { resolveProfilePath } from '../../cc/profiles' @@ -18,22 +19,11 @@ import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle import { sessionLaunch } from '../../cc/sessionTerminal' import { projectsDirFor } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' +import { resolveForge } from '../../forge/context' +import { loadIssues } from '../../forge/issueLoader' +import { mergeForgeState } from '../../forge/state' import { gitFetch, resolveFeatureBranch } from '../../git/branches' -import { detectRepo } from '../../git/remote' import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' -import { - addDependency, - closeIssue, - deleteBranch, - deleteIssue, - getDependencies, - getPullRequest, - GiteaApiError, - mergePullRequest, - removeDependency, -} from '../../gitea/api' -import { loadIssues } from '../../gitea/issueLoader' -import { mergeStateJsonComment } from '../../gitea/stateJson' import { isValidSpxFilePath } from '../../issues/stateFields' import { logger } from '../../logging/logger' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' @@ -42,6 +32,7 @@ import { makeNonce } from '../../webview/html' import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor' import { evaluateLockedReason } from '../issueLock' import { cleanupFeatureBranch } from './branchCleanup' +import { forgeOrToast } from './forgeGate' import { clearSessionConfirmText, clearSessionDoneText, SESSION_KINDS } from './sessionKinds' import * as sessions from './sessions' import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset' @@ -92,44 +83,12 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: }) } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - rollback(undefined) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - rollback(undefined) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { rollback(undefined) return } + const { workspaceRoot } = forge // 1. 读工单状态(共享评论叠加本机 workspaceState 记录)。 let prStr: string | undefined @@ -183,26 +142,13 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: // 跳过 PR 合并 + worktree 清理,仅持久化 column='done'。 if (!prStr) { try { - await mergeStateJsonComment({ - host: remote.host, - owner: remote.owner, - repo: remote.repo, - token, - issueNumber, - extra: { column: 'done' }, - }) + await mergeForgeState(forge.provider, issueNumber, { column: 'done' }) logger.add({ level: 'info', source: 'panel', message: `工单 #${issueNumber} 无 PR,直接标记完成`, }) - await syncCloseGiteaIssue(panel, { - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber, - }) + await syncCloseIssue(panel, forge, issueNumber) panel.postMessage({ type: 'issue/patch', issueNumber, @@ -250,15 +196,9 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: } // 3. Look up PR state on gitea. - let pullRequest: Awaited> + let pullRequest: ForgePullRequest try { - pullRequest = await getPullRequest({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: prIndex, - }) + pullRequest = await forge.provider.getPullRequest(prIndex) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -282,14 +222,14 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: if (!pullRequest.merged) { // PR 还没合并 → 由插件代为合并(用户拖到"完成"列即表示放行)。 // 合并失败(冲突 / 已关闭 / 权限)回滚拖动到原列。 + let outcome: MergeOutcome try { - await mergePullRequest({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: prIndex, - }) + outcome = await forge.provider.mergePullRequest(prIndex, 'merge') + } + catch (err) { + outcome = { merged: false, reason: 'rejected', message: err instanceof Error ? err.message : String(err) } + } + if (outcome.merged) { logger.add({ level: 'info', source: 'panel', @@ -332,10 +272,9 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: }) } } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - const isConflict = err instanceof GiteaApiError - && (err.status === 405 || /conflict/i.test(err.message)) + else { + const message = outcome.message + const isConflict = outcome.reason === 'conflict' logger.add({ level: 'error', source: 'panel', @@ -382,7 +321,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: worktreePath: '', branch: '', prMerged: true, - prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), + prMergedAt: pullRequest.mergedAt ?? new Date().toISOString(), }) logger.add({ level: 'info', @@ -409,13 +348,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: return } - await syncCloseGiteaIssue(panel, { - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber, - }) + await syncCloseIssue(panel, forge, issueNumber) // 删 worktree 前把实施/测试/原型会话整包(jsonl + 子目录)搬到主仓库 projects 目录, // 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。 @@ -531,8 +464,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: branch: featureBranch, issueNumber, devBranch: settingsForBranchCleanup.devBranch, - remote, - token, + forge, }) } else { @@ -555,7 +487,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: branch: null, worktreeExists: false, prMerged: true, - prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), + prMergedAt: pullRequest.mergedAt ?? new Date().toISOString(), }, }) panel.postMessage({ @@ -578,44 +510,12 @@ export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: }) } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - rollback(undefined) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - rollback(undefined) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { rollback(undefined) return } + const { workspaceRoot } = forge // ① 读 state → 守卫(todo no-op / done 拒绝)→ 确认;取消 rollback 必须先有 fromColumn let prStr: string | undefined @@ -761,22 +661,10 @@ export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: return } try { - const pullRequest = await getPullRequest({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: prIndex, - }) + const pullRequest = await forge.provider.getPullRequest(prIndex) prHeadRef = pullRequest.headRef if (!pullRequest.merged) { - await closeIssue({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber: prIndex, - }) + await forge.provider.closePullRequest(prIndex) } } catch (err) { @@ -810,8 +698,7 @@ export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: branch: featureBranch, issueNumber, devBranch: settingsForBranchCleanup.devBranch, - remote, - token, + forge, }) } @@ -874,44 +761,12 @@ export async function handleDropToInProgress(panel: KanbanWebviewPanel, issueNum }) } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { + const forge = await forgeOrToast(panel) + if (!forge) { rollback(undefined) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - rollback(undefined) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - rollback(undefined) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) return } + const { workspaceRoot } = forge // 读工单状态(共享评论叠加本机记录)拿 source column + planFile + // 可选的 profilePath / sessionId。 @@ -1017,55 +872,20 @@ export async function handleCloseIssue(panel: KanbanWebviewPanel, issueNumber: n }) const choice = await window.showWarningMessage( - `确定关闭工单 #${issueNumber}?此操作只会关闭 Gitea 工单,不会清理本地会话、worktree、PR 或分支。`, + `确定关闭工单 #${issueNumber}?此操作只会关闭工单,不会清理本地会话、worktree、PR 或分支。`, { modal: true }, '关闭工单', ) if (choice !== '关闭工单') return - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } try { - await closeIssue({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber, - }) + await forge.provider.closeIssue(issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1115,40 +935,22 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: if (choice !== '删除') return - // 2. workspace / repo / token 前置 - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { + // 2. 解析代码托管仓库;不支持删除工单的仓库在动手清理之前就停下 + const forge = await forgeOrToast(panel) + if (!forge) { + return + } + if (!forge.provider.capabilities.deleteIssue) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, + message: '当前仓库不支持删除工单,请改用关闭', + dismissOnTimer: 6000, }) return } + const { workspaceRoot } = forge // 3. 读工单状态(共享评论叠加本机记录)拿 pr / branch / worktreePath let stateObj: Record = {} @@ -1232,13 +1034,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: const prIndex = Number.parseInt(prStr, 10) if (Number.isFinite(prIndex)) { try { - await closeIssue({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber: prIndex, - }) + await forge.provider.closePullRequest(prIndex) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1257,13 +1053,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: // 7. 删 feature branch(如有) if (branch) { try { - await deleteBranch({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - branch, - }) + await forge.provider.deleteBranch(branch) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1280,13 +1070,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: // 8. 硬删 issue 本身 try { - await deleteIssue({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber, - }) + await forge.provider.deleteIssue(issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1329,41 +1113,11 @@ export async function handleIssueCreate( return } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } + const { workspaceRoot } = forge // 先启动工单通知接收器,确保新工单能关联到本次会话。 const settings = getSettings(panel.context) @@ -1447,7 +1201,7 @@ export async function handleIssueCreate( const storedBrainstormProfile = brainstormProfilePath?.trim() || undefined webhookCoordinator.registerIssueCreation(nonce, { sessionId, - remote, + remote: { host: forge.host, owner: forge.owner, repo: forge.repo }, brainstormProfilePath: storedBrainstormProfile, color, workspaceRoot, @@ -1507,19 +1261,13 @@ export async function handleIssueCreate( * 看板已切到 done 列,rollback 意义不大。失败时打 warn 日志 + warning toast, * 用户可手动关闭工单。 */ -export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: { - host: string - token: string - owner: string - repo: string - issueNumber: number -}): Promise { +export async function syncCloseIssue(panel: KanbanWebviewPanel, forge: ForgeContext, issueNumber: number): Promise { try { - await closeIssue(opts) + await forge.provider.closeIssue(issueNumber) logger.add({ level: 'info', source: 'panel', - message: `已关闭 Gitea 工单 #${opts.issueNumber}`, + message: `已关闭工单 #${issueNumber}`, }) } catch (err) { @@ -1527,14 +1275,14 @@ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: { logger.add({ level: 'warn', source: 'panel', - message: `同步关闭 Gitea 工单 #${opts.issueNumber} 失败`, + message: `同步关闭工单 #${issueNumber} 失败`, details: message, }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', - message: `工单 #${opts.issueNumber} 状态同步失败,请手动关闭: ${message}`, + message: `工单 #${issueNumber} 状态同步失败,请手动关闭: ${message}`, dismissOnTimer: 6000, }) } @@ -1559,22 +1307,10 @@ export async function resolveLockedReason( if (snapshot?.some(i => i.number === issueNumber)) return evaluateLockedReason(snapshot, issueNumber) - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) + const resolved = await resolveForge(panel.context) + if (!resolved.ok) return { locked: false } - const remote = await detectRepo(workspaceRoot) - if (!remote) - return { locked: false } - const token = await getToken(panel.context, remote.host) - if (!token) - return { locked: false } - const { issues } = await loadIssues({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - workspaceRoot, - }) + const { issues } = await loadIssues(resolved.forge) return evaluateLockedReason(issues, issueNumber) } catch (err) { @@ -1590,76 +1326,24 @@ export async function resolveLockedReason( } export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber: number, prerequisiteNumber: number): Promise { - // Gitea-only: dependency links don't map to YouTrack. Ignore for youtrack - // cards rather than calling the gitea API with a synthetic number. + // 依赖只在代码托管仓库的工单之间建:YouTrack 卡片的编号是合成的,不能拿去调接口;仓库没有依赖功能时也不做。 if (panel.isYouTrackIssue(issueNumber) || panel.isYouTrackIssue(prerequisiteNumber)) return - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge || !forge.provider.capabilities.dependencies) { return } try { - // Gitea 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。 - const deps = await getDependencies({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: issueNumber, - }) + // 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。 + const deps = await forge.provider.listDependencies(issueNumber) for (const dep of deps) { - if (dep.number !== prerequisiteNumber) { - await removeDependency({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: issueNumber, - dependencyIndex: dep.number, - }) + if (dep !== prerequisiteNumber) { + await forge.provider.removeDependency(issueNumber, dep) } } - if (!deps.some(d => d.number === prerequisiteNumber)) { - await addDependency({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: issueNumber, - dependencyIndex: prerequisiteNumber, - }) + if (!deps.includes(prerequisiteNumber)) { + await forge.provider.addDependency(issueNumber, prerequisiteNumber) } } catch (err) { @@ -1685,51 +1369,13 @@ export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber export async function handleClearDependency(panel: KanbanWebviewPanel, issueNumber: number, prerequisiteNumber: number): Promise { if (panel.isYouTrackIssue(issueNumber) || panel.isYouTrackIssue(prerequisiteNumber)) return - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } try { - await removeDependency({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: issueNumber, - dependencyIndex: prerequisiteNumber, - }) + await forge.provider.removeDependency(issueNumber, prerequisiteNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1758,39 +1404,8 @@ export async function handleClearDependency(panel: KanbanWebviewPanel, issueNumb * that value; we never write back `undefined` (the user explicitly chose). */ export async function handleUpdateAutoReview(panel: KanbanWebviewPanel, issueNumber: number, value: boolean): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -1843,39 +1458,8 @@ export async function handleUpdateAutoReview(panel: KanbanWebviewPanel, issueNum } export async function handleUpdateReviewPassed(panel: KanbanWebviewPanel, issueNumber: number, value: boolean): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -1929,39 +1513,8 @@ export async function handleUpdateReviewPassed(panel: KanbanWebviewPanel, issueN * back via `issue/patch` if the persist fails. */ export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNumber: number, profilePath: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -2094,39 +1647,8 @@ export async function handleClearSession(panel: KanbanWebviewPanel, issueNumber: * empty = 设置里的「头脑风暴默认配置文件」,再空 = getDefaultProfilePath()。与实施/测试 profile 互不回退。 */ export async function handleUpdateBrainstormProfilePath(panel: KanbanWebviewPanel, issueNumber: number, brainstormProfilePath: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -2178,39 +1700,8 @@ export async function handleUpdateBrainstormProfilePath(panel: KanbanWebviewPane * empty = 设置里的「测试默认配置文件」,再空 = getDefaultProfilePath(),不回退实施 profile。 */ export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, issueNumber: number, testProfilePath: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -2267,39 +1758,8 @@ export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, iss * 空 = 设置里的「原型默认配置文件」,再空 = getDefaultProfilePath(),与其它会话互不回退。 */ export async function handleUpdatePrototypeProfilePath(panel: KanbanWebviewPanel, issueNumber: number, prototypeProfilePath: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - return - } - - const remote = await detectRepo(workspaceRoot) - if (!remote) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '当前工作区没有 Gitea 远程仓库', - dismissOnTimer: 5000, - }) - return - } - - const token = await getToken(panel.context, remote.host) - if (!token) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先完成 Gitea 配置', - dismissOnTimer: 5000, - }) + const forge = await forgeOrToast(panel) + if (!forge) { return } @@ -2391,37 +1851,22 @@ export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string) * button. */ export async function handleOpenPr(panel: KanbanWebviewPanel, pr: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') + const resolved = await resolveForge(panel.context) + if (!resolved.ok) { + void window.showErrorMessage(resolved.message) return } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const url = `https://${remote.host}/${remote.owner}/${remote.repo}/pulls/${pr}` - void env.openExternal(Uri.parse(url)) + void env.openExternal(Uri.parse(resolved.forge.provider.pullRequestWebUrl(Number(pr)))) } export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') - return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - void window.showErrorMessage('请先完成 Gitea 配置') + const resolved = await resolveForge(panel.context) + if (!resolved.ok) { + void window.showErrorMessage(resolved.message) return } + const { workspaceRoot } = resolved.forge try { const state = await panel.readIssueState(issueNumber) diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts index 01bc6dc..447b607 100644 --- a/vscode/src/panel/handlers/prFiles.ts +++ b/vscode/src/panel/handlers/prFiles.ts @@ -1,14 +1,15 @@ /** - * 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取 + * 「改动」tab 的扩展侧 handler:实时从代码托管仓库读整个 PR 的改动文件清单,并按需取 * 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。 * * 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json` * (按文件变更指纹失效:作者推新提交后,仅真改动过的文件掉勾,没动的保留)。diff - * 左右两侧用 merge_base → head 的原文,与 Gitea 网页 diff 口径一致;getRawFile 对 + * 左右两侧用 merge_base → head 的原文,与网页上的 PR diff 口径一致;getRawFile 对 * 404 兜底空串,故新增/删除/改名天然成立。 */ import type { PrDiffMode } from '../../cc/prDiffMode' +import type { ForgeProvider, ForgePullRequest } from '../../forge/types' import type { KanbanWebviewPanel } from '../KanbanPanel' import { accessSync, constants } from 'node:fs' import { createPatch } from 'diff' @@ -17,26 +18,31 @@ import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { parsePrDiffMode } from '../../cc/prDiffMode' import { resolveProfilePath } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' -import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api' -import { resolveRepoContext } from '../../gitea/repoContext' -import { readStateJsonComment } from '../../gitea/stateJson' +import { resolveForge } from '../../forge/context' import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore' import { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore' import { getEffectivePrDiffSummaryProfilePath } from '../../settings/store' import { makeNonce } from '../../webview/html' /** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */ -async function resolvePrNumber( - ctx: { host: string, owner: string, repo: string, token: string }, - issueNumber: number, -): Promise { - const stateObj = await readStateJsonComment({ - host: ctx.host, - owner: ctx.owner, - repo: ctx.repo, - token: ctx.token, - issueNumber, - }) +/** PR 里一个文件改动前后的原文:左边是合并基上的 `oldPath`,右边是 head 上的 `path`。 */ +async function prFileSides( + provider: ForgeProvider, + prNumber: number, + path: string, + oldPath: string, +): Promise<{ pr: ForgePullRequest, oldContent: string, newContent: string }> { + const pr = await provider.getPullRequest(prNumber) + const base = await provider.pullRequestDiffBase(pr) + const [oldContent, newContent] = await Promise.all([ + provider.getRawFile(oldPath, base), + provider.getRawFile(path, pr.headSha), + ]) + return { pr, oldContent, newContent } +} + +async function resolvePrNumber(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const stateObj = await panel.readIssueState(issueNumber) const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined return pr ? Number(pr) : undefined } @@ -104,45 +110,34 @@ function fileSig(f: { status: string, additions: number, deletions: number }): s */ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { - const ctx = await resolveRepoContext(panel.context) - if (!ctx) { + const resolved = await resolveForge(panel.context) + if (!resolved.ok) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } - const index = await resolvePrNumber(ctx, issueNumber) + const index = await resolvePrNumber(panel, issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } - const files = await listPullRequestFiles({ - host: ctx.host, - owner: ctx.owner, - repo: ctx.repo, - token: ctx.token, - index, - }) + const files = await resolved.forge.provider.listPullRequestFiles(index) - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - let confirmed: string[] = [] - let summaries: Record = {} - if (workspaceRoot) { - const [stored, sums] = await Promise.all([ - readConfirmedSigs(workspaceRoot, issueNumber), - readFileSummaries(workspaceRoot, issueNumber), - ]) - summaries = sums - // 当前每文件的变更指纹;仅指纹相符的已查看条目仍有效。 - const cur = new Map(files.map(f => [f.path, fileSig(f)])) - confirmed = Object.keys(stored).filter(p => cur.get(p) === stored[p]) - // 有失配/消失才写盘,剪掉陈旧条目,避免无谓磁盘写入。 - if (confirmed.length !== Object.keys(stored).length) { - const pruned: Record = {} - for (const p of confirmed) - pruned[p] = stored[p] - await writeConfirmedSigs(workspaceRoot, issueNumber, pruned) - } + const { workspaceRoot } = resolved.forge + const [stored, summaries] = await Promise.all([ + readConfirmedSigs(workspaceRoot, issueNumber), + readFileSummaries(workspaceRoot, issueNumber), + ]) + // 当前每文件的变更指纹;仅指纹相符的已查看条目仍有效。 + const cur = new Map(files.map(f => [f.path, fileSig(f)])) + const confirmed = Object.keys(stored).filter(p => cur.get(p) === stored[p]) + // 有失配/消失才写盘,剪掉陈旧条目,避免无谓磁盘写入。 + if (confirmed.length !== Object.keys(stored).length) { + const pruned: Record = {} + for (const p of confirmed) + pruned[p] = stored[p] + await writeConfirmedSigs(workspaceRoot, issueNumber, pruned) } panel.postMessage({ type: 'pr-files/show', issueNumber, files, confirmed, summaries }) @@ -168,24 +163,19 @@ export async function handleGetPrFileDiff( args: { issueNumber: number, path: string, previousPath?: string }, ): Promise { try { - const ctx = await resolveRepoContext(panel.context) - if (!ctx) { + const resolved = await resolveForge(panel.context) + if (!resolved.ok) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' }) return } - const index = await resolvePrNumber(ctx, args.issueNumber) + const index = await resolvePrNumber(panel, args.issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' }) return } - const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) - const mergeBase = pr.mergeBase || pr.baseSha const oldPath = args.previousPath ?? args.path - const [oldContent, newContent] = await Promise.all([ - getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }), - getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: args.path, ref: pr.headSha }), - ]) + const { oldContent, newContent } = await prFileSides(resolved.forge.provider, index, args.path, oldPath) panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, @@ -256,12 +246,12 @@ export async function handleGeneratePrFileSummary( ): Promise { const { issueNumber, path: filePath } = args try { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const ctx = await resolveRepoContext(panel.context) - if (!ctx || !workspaceRoot) { - panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '当前工作区没有 Gitea 仓库或未配置 token' }) + const resolved = await resolveForge(panel.context) + if (!resolved.ok) { + panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: resolved.message }) return } + const { workspaceRoot } = resolved.forge const raw = await getEffectivePrDiffSummaryProfilePath(panel.context) const resolution = resolveCommitProfileForRun( @@ -292,19 +282,13 @@ export async function handleGeneratePrFileSummary( return } - const index = await resolvePrNumber(ctx, issueNumber) + const index = await resolvePrNumber(panel, issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '该工单还没有 PR' }) return } - const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) - const mergeBase = pr.mergeBase || pr.baseSha - const oldPath = args.previousPath ?? filePath - const [oldContent, newContent] = await Promise.all([ - getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }), - getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: filePath, ref: pr.headSha }), - ]) + const { pr, oldContent, newContent } = await prFileSides(resolved.forge.provider, index, filePath, args.previousPath ?? filePath) const patch = createPatch(filePath, oldContent, newContent, '', '') const prevSession = await readSummarySession(workspaceRoot, issueNumber) diff --git a/vscode/src/panel/handlers/sessionHandoff.ts b/vscode/src/panel/handlers/sessionHandoff.ts index 0a5e729..1ef9ebf 100644 --- a/vscode/src/panel/handlers/sessionHandoff.ts +++ b/vscode/src/panel/handlers/sessionHandoff.ts @@ -31,7 +31,6 @@ import { sessionInboxPrefix, } from '../../cc/sessionHandoff' import { projectsDirFor } from '../../cc/sessionWatcher' -import { listRepoAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' import { deleteHandoffBundle, @@ -52,7 +51,7 @@ export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Prom return } try { - const users = await listRepoAssignees(ctx) + const users = await ctx.provider.listAssignees() panel.postMessage({ type: 'session-handoff/users-result', users: users.map(u => u.login).filter(l => l !== ctx.me).sort(), diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 934b32c..a01bc83 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -6,18 +6,16 @@ import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as path from 'node:path' import { window, workspace } from 'vscode' -import { loginForToken } from '../../auth/identity' -import { getToken } from '../../auth/secrets' +import { loginFor } from '../../auth/identity' import { buildCcArgv } from '../../cc/ccCommand' import { buildCodexArgv } from '../../cc/codexCommand' import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts' import { sessionLaunch } from '../../cc/sessionTerminal' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' -import { detectRepo } from '../../git/remote' +import { resolveForge } from '../../forge/context' import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath' -import { updateIssueAssignees } from '../../gitea/api' import { getLocalIssueState } from '../../issues/localState' import { logger } from '../../logging/logger' import { claudeSessionFileExists, codexSessionFileExists } from '../../sessions/sessionFiles' @@ -27,6 +25,7 @@ import { makeNonce } from '../../webview/html' import { decideAutoReviewPath } from '../autoReviewDecision' import { pickBrainstormContinuePath } from '../brainstormContinue' import { cleanupFeatureBranch } from './branchCleanup' +import { forgeOrErrorMessage } from './forgeGate' import { planResumeCwd, resumeTerminalName } from './resumeTarget' import { cancelReviewSessionCapture, prepareReviewSessionCapture, recoverReviewSessionCapture } from './reviewSessionCapture' import { submitToSessionTerminal } from './terminals' @@ -632,21 +631,10 @@ export async function handleImplement( message: `开始实施 #${issueNumber}`, details: `planFile=${planFile}`, }) - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') + const forge = await forgeOrErrorMessage(panel) + if (!forge) return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - void window.showErrorMessage('请先完成 Gitea 配置') - return - } + const { workspaceRoot } = forge // worktree 目录名用 spec/plan 的 slug(可读、与 spec 目录同名),不再用 planFile 的 hash。 const slug = deriveSlug(planFile) @@ -686,8 +674,7 @@ export async function handleImplement( branch, issueNumber, devBranch: settings.devBranch, - remote, - token, + forge, }) // worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在; // git worktree add 不会创建多层父目录,先补齐。 @@ -711,17 +698,10 @@ export async function handleImplement( // 实施——门禁侧会退回本地终端兜底。 void (async () => { try { - const me = await loginForToken(remote.host, token) + const me = await loginFor(forge) if (!me) return - await updateIssueAssignees({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - index: issueNumber, - assignees: [me], - }) + await forge.provider.setIssueAssignees(issueNumber, [me]) logger.add({ level: 'info', source: 'implement', @@ -887,21 +867,10 @@ export async function handleImplement( * 全程在 workspaceRoot,不建 worktree / 分支(这是讨论,不是编码)。 */ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueNumber: number): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') + const forge = await forgeOrErrorMessage(panel) + if (!forge) return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - void window.showErrorMessage('请先完成 Gitea 配置') - return - } + const { workspaceRoot } = forge // state 读一次就够:profile / 会话 id 和提示词上下文(原型插件、会话 id 等) // 都从这份里取。读不到就按空状态走,提示词里的条件块自然走 else 分支。 @@ -1049,21 +1018,10 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum return } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') + const forge = await forgeOrErrorMessage(panel) + if (!forge) return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - void window.showErrorMessage('请先完成 Gitea 配置') - return - } + const { workspaceRoot } = forge // 从 state JSON 读 pr / testProfile / worktree。pr 仅在主 worktree(已合并) // 测试时必需——那条路径要靠 tea 了解合并进来的改动;worktree 内测试代码 @@ -1254,9 +1212,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session let branchForHook = '' let testProfilePath: string | undefined if (workspaceRoot) { - const remote = await detectRepo(workspaceRoot) - const token = remote ? await getToken(panel.context, remote.host) : undefined - if (remote && token) { + if ((await resolveForge(panel.context)).ok) { try { const stateObj = await panel.readIssueState(issueNumber) const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined @@ -1366,21 +1322,10 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss return } - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') + const forge = await forgeOrErrorMessage(panel) + if (!forge) return - } - const remote = await detectRepo(workspaceRoot) - if (!remote) { - void window.showErrorMessage('当前工作区没有 Gitea 远程仓库') - return - } - const token = await getToken(panel.context, remote.host) - if (!token) { - void window.showErrorMessage('请先完成 Gitea 配置') - return - } + const { workspaceRoot } = forge const lockCheck = await panel.resolveLockedReason(issueNumber) if (lockCheck.locked) { @@ -1516,9 +1461,7 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se let branchForHook = '' let prototypeProfilePath: string | undefined if (workspaceRoot) { - const remote = await detectRepo(workspaceRoot) - const token = remote ? await getToken(panel.context, remote.host) : undefined - if (remote && token) { + if ((await resolveForge(panel.context)).ok) { try { const stateObj = await panel.readIssueState(issueNumber) const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 6f33589..8970894 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -2,7 +2,7 @@ import type { Terminal, TerminalEditorLocationOptions, } from 'vscode' -import type { Issue } from '../../gitea/types' +import type { Issue } from '../../issues/types' import type { KanbanWebviewPanel } from '../KanbanPanel' import { TabInputTerminal, window } from 'vscode' import { logger } from '../../logging/logger' diff --git a/vscode/src/panel/handlers/todoReset.ts b/vscode/src/panel/handlers/todoReset.ts index cb33beb..c5bf8c8 100644 --- a/vscode/src/panel/handlers/todoReset.ts +++ b/vscode/src/panel/handlers/todoReset.ts @@ -1,4 +1,4 @@ -import type { IssueColumn } from '../../gitea/types' +import type { IssueColumn } from '../../issues/types' export function canResetToTodo(fromColumn: IssueColumn | undefined): boolean { return fromColumn === 'in-progress' || fromColumn === 'review' diff --git a/vscode/src/panel/handlers/worktree.ts b/vscode/src/panel/handlers/worktree.ts index be47676..c19453b 100644 --- a/vscode/src/panel/handlers/worktree.ts +++ b/vscode/src/panel/handlers/worktree.ts @@ -2,8 +2,7 @@ import type { HookContext } from '../../git/worktreeHooks' import type { KanbanWebviewPanel } from '../KanbanPanel' import { execFile } from 'node:child_process' import { commands, Uri, window, workspace } from 'vscode' -import { getToken } from '../../auth/secrets' -import { detectRepo } from '../../git/remote' +import { resolveForge } from '../../forge/context' import { expandTilde, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { runImplTabPostCloseHook, @@ -130,17 +129,15 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe console.warn('[superpowers] failed to clear worktree state JSON:', err) } - // 连同 feature 分支一起删(本地 + 远程,best-effort)。受保护分支跳过、缺 token 只删本地, + // 连同 feature 分支一起删(本地 + 远程,best-effort)。受保护分支跳过、解析不出代码托管仓库只删本地, // 逻辑与「拖到完成」共用同一个 cleanupFeatureBranch。 - const remote = await detectRepo(workspaceRoot) - const token = remote ? await getToken(panel.context, remote.host) : undefined + const resolved = await resolveForge(panel.context) await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settingsForHook.devBranch, - remote: remote ? { host: remote.host, owner: remote.owner, repo: remote.repo } : undefined, - token, + forge: resolved.ok ? resolved.forge : undefined, }) // null 才能过 postMessage 序列化;undefined 会被丢掉,webview 合并不清字段 @@ -299,15 +296,10 @@ export async function dispatchWorktreeHook( */ export async function dispatchImplTabPostCloseAsync(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - return - const remote = await detectRepo(workspaceRoot) - if (!remote) - return - const token = await getToken(panel.context, remote.host) - if (!token) + const resolved = await resolveForge(panel.context) + if (!resolved.ok) return + const { workspaceRoot } = resolved.forge const stateObj = await panel.readIssueState(issueNumber) const branch = typeof stateObj.branch === 'string' ? stateObj.branch : '' const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : '' diff --git a/vscode/src/panel/handlers/youtrackIssues.ts b/vscode/src/panel/handlers/youtrackIssues.ts index 5cbd405..499cc38 100644 --- a/vscode/src/panel/handlers/youtrackIssues.ts +++ b/vscode/src/panel/handlers/youtrackIssues.ts @@ -7,7 +7,7 @@ * only write-back the user asked for. */ -import type { IssueColumn } from '../../gitea/types' +import type { IssueColumn } from '../../issues/types' import type { YouTrackAuth } from '../../youtrack/api' import type { KanbanWebviewPanel } from '../KanbanPanel' import { ThemeIcon, window, workspace } from 'vscode' diff --git a/vscode/src/panel/issueLock.test.ts b/vscode/src/panel/issueLock.test.ts index 8e4683d..e470d06 100644 --- a/vscode/src/panel/issueLock.test.ts +++ b/vscode/src/panel/issueLock.test.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import { describe, expect, it } from 'vitest' import { evaluateLockedReason } from './issueLock' diff --git a/vscode/src/panel/issueLock.ts b/vscode/src/panel/issueLock.ts index 4fcd409..f581120 100644 --- a/vscode/src/panel/issueLock.ts +++ b/vscode/src/panel/issueLock.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' export interface LockedReason { locked: boolean diff --git a/vscode/src/panel/kanbanSnapshot.ts b/vscode/src/panel/kanbanSnapshot.ts index f0d4ce8..101d183 100644 --- a/vscode/src/panel/kanbanSnapshot.ts +++ b/vscode/src/panel/kanbanSnapshot.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' export const KANBAN_SNAPSHOT_KEY = 'superpowers.kanbanSnapshot' diff --git a/vscode/src/panel/kanbanState.test.ts b/vscode/src/panel/kanbanState.test.ts index fcecbc9..3f1a3dd 100644 --- a/vscode/src/panel/kanbanState.test.ts +++ b/vscode/src/panel/kanbanState.test.ts @@ -1,5 +1,5 @@ import type { Memento } from 'vscode' -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import type { KanbanSnapshot } from './kanbanSnapshot' import { describe, expect, it, vi } from 'vitest' import { KANBAN_SNAPSHOT_KEY } from './kanbanSnapshot' diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 1bf0be2..7b99630 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -1,5 +1,5 @@ import type { ReleaseCommitGroup } from '../git/release' -import type { Issue, IssueColumn } from '../gitea/types' +import type { Issue, IssueColumn } from '../issues/types' import type { LogEntry } from '../logging/logger' import type { ProfilesData } from '../profiles/store' import type { ManagedSession } from '../sessions/managedStore' diff --git a/vscode/src/sessions/sessionFiles.test.ts b/vscode/src/sessions/sessionFiles.test.ts index 6130fe7..6adddc8 100644 --- a/vscode/src/sessions/sessionFiles.test.ts +++ b/vscode/src/sessions/sessionFiles.test.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import { promises as fsp, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' diff --git a/vscode/src/sessions/sessionFiles.ts b/vscode/src/sessions/sessionFiles.ts index 5e82012..edc9e26 100644 --- a/vscode/src/sessions/sessionFiles.ts +++ b/vscode/src/sessions/sessionFiles.ts @@ -11,7 +11,7 @@ * 与 worktree 无关。 */ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' diff --git a/vscode/src/shared/kanbanUpdates.test.ts b/vscode/src/shared/kanbanUpdates.test.ts index 52995b8..b380b28 100644 --- a/vscode/src/shared/kanbanUpdates.test.ts +++ b/vscode/src/shared/kanbanUpdates.test.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import { describe, expect, it } from 'vitest' import { applyIssueUpdate } from './kanbanUpdates' diff --git a/vscode/src/shared/kanbanUpdates.ts b/vscode/src/shared/kanbanUpdates.ts index f994a16..2f53d09 100644 --- a/vscode/src/shared/kanbanUpdates.ts +++ b/vscode/src/shared/kanbanUpdates.ts @@ -1,4 +1,4 @@ -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' export type IssueUpdate = | { type: 'issue/patch', issueNumber: number, patch: Record } diff --git a/vscode/src/webhook/coordinator.cache.test.ts b/vscode/src/webhook/coordinator.cache.test.ts index 33beed1..54bf6b9 100644 --- a/vscode/src/webhook/coordinator.cache.test.ts +++ b/vscode/src/webhook/coordinator.cache.test.ts @@ -16,14 +16,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('vscode', () => ({ env: {}, Uri: {}, window: { showInformationMessage: async () => undefined }, workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] } })) vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) vi.mock('../auth/secrets', () => ({ getToken: mocks.token })) -vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' })) +vi.mock('../auth/identity', () => ({ loginFor: async () => 'me' })) vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) })) -vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) })) -vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle })) -vi.mock('../gitea/stateJson', () => ({ - readStateJsonComment: mocks.readState, - mergeStateJsonComment: async () => undefined, +vi.mock('../forge/gitea/provider', () => ({ + GiteaProvider: class { + kind = 'gitea' + getIssue = async () => ({ assignees: [{ login: 'me' }] }) + }, +})) +vi.mock('../forge/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle })) +vi.mock('../forge/state', () => ({ + readForgeState: mocks.readState, + mergeForgeState: async () => undefined, })) vi.mock('../issues/localState', () => ({ overlayLocalIssueState: (_ctx: unknown, issues: unknown[]) => issues, getLocalIssueState: () => ({}) })) vi.mock('../panel/handlers/issueChat', () => ({})) diff --git a/vscode/src/webhook/coordinator.issueCreation.test.ts b/vscode/src/webhook/coordinator.issueCreation.test.ts index 71b18ce..54f4a4f 100644 --- a/vscode/src/webhook/coordinator.issueCreation.test.ts +++ b/vscode/src/webhook/coordinator.issueCreation.test.ts @@ -1,5 +1,5 @@ import type { ExtensionContext, Terminal } from 'vscode' -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { IssueWebhookEvent } from './eventParser' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,17 +32,22 @@ vi.mock('node:fs', async importOriginal => ({ })) vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) vi.mock('../auth/secrets', () => ({ getToken: mocks.token })) -vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' })) +vi.mock('../auth/identity', () => ({ loginFor: async () => 'me' })) vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) vi.mock('../git/remote', async importOriginal => ({ ...(await importOriginal()), detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }), })) -vi.mock('../gitea/api', () => ({ getIssue: mocks.getIssue })) -vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.load })) -vi.mock('../gitea/stateJson', () => ({ - readStateJsonComment: mocks.readShared, - mergeStateJsonComment: mocks.mergeShared, +vi.mock('../forge/gitea/provider', () => ({ + GiteaProvider: class { + kind = 'gitea' + getIssue = mocks.getIssue + }, +})) +vi.mock('../forge/issueLoader', () => ({ loadSingleIssue: mocks.load })) +vi.mock('../forge/state', () => ({ + readForgeState: mocks.readShared, + mergeForgeState: mocks.mergeShared, })) vi.mock('../panel/handlers/issueChat', () => ({})) vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues })) @@ -173,7 +178,7 @@ describe('issue creation reconciliation', () => { expect(f.panel.newIssueTerminals.get(483)).toBe(f.terminal) expect(f.panel.terminals.get(SID)).toBe(f.terminal) expect(f.panel.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'issue/append', issue: expect.objectContaining({ sessionId: SID }) })) - expect(mocks.mergeShared).toHaveBeenCalledWith(expect.objectContaining({ defaults: { column: 'todo', color: 'terminal.ansiBlue' }, extra: {} })) + expect(mocks.mergeShared).toHaveBeenCalledWith(expect.anything(), expect.any(Number), {}, { column: 'todo', color: 'terminal.ansiBlue' }) }) it.each(['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'])('reconciles before handling %s', async (action) => { @@ -291,7 +296,7 @@ describe('issue creation reconciliation', () => { f.register() mocks.getIssue.mockResolvedValue({ state: 'closed' }) await dispatch(event('assigned')) - expect(mocks.mergeShared).toHaveBeenCalledWith(expect.objectContaining({ defaults: { column: 'done', color: 'terminal.ansiBlue' }, extra: {} })) + expect(mocks.mergeShared).toHaveBeenCalledWith(expect.anything(), expect.any(Number), {}, { column: 'done', color: 'terminal.ansiBlue' }) }) it('saves while the board is closed and restores terminal links when reopened', async () => { diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 29f08cf..bc331f8 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -10,6 +10,7 @@ */ import type { ExtensionContext, Terminal } from 'vscode' +import type { ForgeContext } from '../forge/context' import type { Remote } from '../git/remote' import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanScope } from '../panel/kanbanScope' @@ -18,15 +19,14 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { execFile } from 'node:child_process' import { promises as fsp } from 'node:fs' import { env, Uri, window, workspace } from 'vscode' -import { loginForToken } from '../auth/identity' -import { getToken } from '../auth/secrets' +import { loginFor } from '../auth/identity' import { renderReviewPrompt } from '../cc/prompts' -import { detectRepo, parseRemoteUrl } from '../git/remote' -import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api' -import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' -import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' -import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' -import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' +import { openForge, resolveForge } from '../forge/context' +import { cleanCommentSummary, systemCommentText } from '../forge/issueComment' +import { loadIssues, loadSingleIssue } from '../forge/issueLoader' +import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../forge/markers' +import { mergeForgeState, mergeForgeStateGuarded, readForgeState } from '../forge/state' +import { parseRemoteUrl } from '../git/remote' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { hasStateColumn, parseStateComment } from '../issues/stateComment' import { logger } from '../logging/logger' @@ -386,19 +386,14 @@ class WebhookCoordinator { logger.add({ level: 'info', source: 'webhook', message: `已关联工单 #${issueNumber} 与新建会话`, details: `nonce=${nonce} sessionId=${linked.sessionId}` }) try { - const token = await getToken(ctx, pending.remote.host) - if (!token) - throw new Error('未配置 Gitea token') - const issue = await getIssue({ ...pending.remote, token, index: issueNumber }) + const resolved = await openForge(ctx, pending.workspaceRoot, pending.remote) + if (!resolved.ok) + throw new Error(resolved.message) + const { provider } = resolved.forge + const issue = await provider.getIssue(issueNumber) if (!issue) throw new Error('工单不存在') - await mergeStateJsonComment({ - ...pending.remote, - token, - issueNumber, - defaults: { column: issue.state === 'closed' ? 'done' : 'todo', color: pending.color }, - extra: {}, - }) + await mergeForgeState(provider, issueNumber, {}, { column: issue.state === 'closed' ? 'done' : 'todo', color: pending.color }) } catch (err) { logger.add({ level: 'warn', source: 'webhook', message: `工单 #${issueNumber} 默认状态保存失败,本地会话关联已保留`, details: String(err) }) @@ -437,43 +432,29 @@ class WebhookCoordinator { } /** - * Resolve the repo context for an incoming webhook event. The pendingHooks - * map is only a convenience cache — the webhook URL path itself proves the - * issue belongs to us, so a cache miss is recoverable via detectRepo. Returns - * null only when there's no workspace, no origin remote, or no saved token. + * 给 webhook 事件找对应的代码托管仓库。pendingHooks 只是缓存:webhook 地址本身就说明这个工单归我们管, + * 缓存没有时按当前工作区的 origin 恢复。没工作区、没 origin、没凭据时返回 null。 */ - private async resolveRepoContext(issueNumber: number): Promise<{ - hookId: number | undefined - host: string - owner: string - repo: string - token: string - } | null> { + private async forgeForEvent(issueNumber: number): Promise { if (!this.ctx) return null - const pending = this.pendingHooks.get(issueNumber) - if (pending) { - const tok = await getToken(this.ctx, pending.host) - if (!tok) - return null - return { hookId: pending.hookId, host: pending.host, owner: pending.owner, repo: pending.repo, token: tok } - } - // Recover via detectRepo + getToken when the in-memory map is empty. const ws = workspace.workspaceFolders?.[0]?.uri.fsPath if (!ws) return null - const remote = await detectRepo(ws) - if (!remote) - return null - const tok = await getToken(this.ctx, remote.host) - if (!tok) + const pending = this.pendingHooks.get(issueNumber) + if (pending) { + const resolved = await openForge(this.ctx, ws, pending) + return resolved.ok ? resolved.forge : null + } + const resolved = await resolveForge(this.ctx) + if (!resolved.ok) return null logger.add({ level: 'info', source: 'webhook', - message: `未在 pendingHooks 中找到 #${issueNumber},已通过 detectRepo 恢复仓库上下文`, + message: `未在 pendingHooks 中找到 #${issueNumber},已按工作区 origin 恢复仓库上下文`, }) - return { hookId: undefined, host: remote.host, owner: remote.owner, repo: remote.repo, token: tok } + return resolved.forge } /** @@ -487,12 +468,12 @@ class WebhookCoordinator { */ private async resolveOwnership( issueNumber: number, - ctx: { host: string, owner: string, repo: string, token: string }, + ctx: ForgeContext, evidenceKinds?: readonly string[], ): Promise<'own' | 'observe'> { let assignees: string[] = [] try { - const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber }) + const issue = await ctx.provider.getIssue(issueNumber) assignees = (issue?.assignees ?? []).map(a => a.login) } catch (err) { @@ -504,7 +485,7 @@ class WebhookCoordinator { details: message, }) } - const me = await loginForToken(ctx.host, ctx.token) + const me = await loginFor(ctx) const hasLocalSessionEvidence = hasLiveIssueSessionTerminal(issueNumber, evidenceKinds) const decision = decideEventOwnership({ assignees, me, hasLocalSessionEvidence }) logger.add({ @@ -554,16 +535,10 @@ class WebhookCoordinator { if (this.activePanel && event.branch) { try { - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) + const resolved = await resolveForge(this.ctx!) + if (!resolved.ok) return null - const remote = await detectRepo(ws) - if (!remote) - return null - const tok = await getToken(this.ctx!, remote.host) - if (!tok) - return null - const { issues } = await loadIssues({ host: remote.host, token: tok, owner: remote.owner, repo: remote.repo }) + const { issues } = await loadIssues(resolved.forge) const found = issues.find(i => i.branch === event.branch) if (found) { logger.add({ @@ -767,33 +742,16 @@ class WebhookCoordinator { if (!this.activePanel) return - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) { + const resolved = await resolveForge(this.ctx) + if (!resolved.ok) { logger.add({ level: 'warn', source: 'webhook', - message: `${reason} 事件忽略:未打开工作区`, - }) - return - } - const remote = await detectRepo(ws) - if (!remote) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `${reason} 事件忽略:工作区未关联 gitea`, - }) - return - } - const token = await getToken(this.ctx, remote.host) - if (!token) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `${reason} 事件忽略:未配置 token`, + message: `${reason} 事件忽略:${resolved.message}`, }) return } + const { forge } = resolved // 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡, // 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析, @@ -806,7 +764,7 @@ class WebhookCoordinator { const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null poster = payload?.issue?.user?.login assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) - me = await loginForToken(remote.host, token) + me = await loginFor(forge) } if (!decideIssueAppend({ scope, pendingCreatedLocally: creation !== undefined, poster, assignees, me })) { logger.add({ @@ -820,15 +778,8 @@ class WebhookCoordinator { return } try { - const loaded = await loadSingleIssue({ - host: remote.host, - owner: remote.owner, - repo: remote.repo, - token, - workspaceRoot: ws, - issueNumber, - }) - const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null + const loaded = await loadSingleIssue(forge, issueNumber) + const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], forge.workspaceRoot)))[0] : null // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新 // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。 const panel = this.activePanel @@ -886,33 +837,16 @@ class WebhookCoordinator { if (!this.ctx) return - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) { + const resolved = await resolveForge(this.ctx) + if (!resolved.ok) { logger.add({ level: 'warn', source: 'webhook', - message: 'issue edited 事件忽略:未打开工作区', - }) - return - } - const remote = await detectRepo(ws) - if (!remote) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'issue edited 事件忽略:工作区未关联 gitea', - }) - return - } - const token = await getToken(this.ctx, remote.host) - if (!token) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'issue edited 事件忽略:未配置 token', + message: `issue edited 事件忽略:${resolved.message}`, }) return } + const { forge } = resolved const found = parseBodyMarkers(event.body) if (Object.keys(found).length === 0) { @@ -926,20 +860,14 @@ class WebhookCoordinator { let currentState: Record = {} try { - currentState = await readStateJsonComment({ - host: remote.host, - token, - owner: remote.owner, - repo: remote.repo, - issueNumber: event.issueNumber, - }) + currentState = await readForgeState(forge.provider, event.issueNumber) } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'webhook', - message: 'readStateJsonComment 失败(继续尝试合并)', + message: '读状态失败(继续尝试合并)', details: msg, }) } @@ -957,7 +885,7 @@ class WebhookCoordinator { changed = true } if (!changed) { - // Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment + // 共享状态已是最新(spx 同步写过的情况),跳过合并写入 // 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠 // coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去, // 否则底部 property grid 不刷新。 @@ -978,7 +906,7 @@ class WebhookCoordinator { // 额外认规划终端。observe 机器只把新值推给本机 webview。 const ownership = await this.resolveOwnership( event.issueNumber, - { host: remote.host, owner: remote.owner, repo: remote.repo, token }, + forge, ['实施', '测试', '规划'], ) if (ownership === 'observe') { @@ -991,21 +919,14 @@ class WebhookCoordinator { } try { - await mergeStateJsonComment({ - host: remote.host, - owner: remote.owner, - repo: remote.repo, - token, - issueNumber: event.issueNumber, - extra: found, - }) + await mergeForgeState(forge.provider, event.issueNumber, found) } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'webhook', - message: 'mergeStateJsonComment 失败(issue edited)', + message: '合并状态失败(issue edited)', details: msg, }) return @@ -1033,7 +954,7 @@ class WebhookCoordinator { private async handlePrOpened(event: ResolvedWebhookEvent): Promise { if (!this.ctx) return - const ctx = await this.resolveRepoContext(event.issueNumber) + const ctx = await this.forgeForEvent(event.issueNumber) if (!ctx) { logger.add({ level: 'error', @@ -1055,17 +976,7 @@ class WebhookCoordinator { } try { - await mergeStateJsonComment({ - host: ctx.host, - owner: ctx.owner, - repo: ctx.repo, - token: ctx.token, - issueNumber: event.issueNumber, - extra: { - pr: event.pr, - implementStatus: 'done', - }, - }) + await mergeForgeState(ctx.provider, event.issueNumber, { pr: event.pr, implementStatus: 'done' }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1122,13 +1033,7 @@ class WebhookCoordinator { let effectiveAutoReview = getSettings(this.ctx).autoReview let autoReviewSource: 'issue' | 'global' = 'global' try { - const stateJson = await readStateJsonComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber: event.issueNumber, - }) + const stateJson = await readForgeState(ctx.provider, event.issueNumber) if (typeof stateJson.autoReview === 'boolean') { effectiveAutoReview = stateJson.autoReview autoReviewSource = 'issue' @@ -1168,7 +1073,7 @@ class WebhookCoordinator { private async handlePrSynchronize(event: ResolvedWebhookEvent): Promise { if (!this.ctx) return - const ctx = await this.resolveRepoContext(event.issueNumber) + const ctx = await this.forgeForEvent(event.issueNumber) if (!ctx) { logger.add({ level: 'error', @@ -1187,13 +1092,7 @@ class WebhookCoordinator { let autoReviewSource: 'issue' | 'global' = 'global' let stateJson: Record | undefined try { - stateJson = await readStateJsonComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber: event.issueNumber, - }) + stateJson = await readForgeState(ctx.provider, event.issueNumber) if (typeof stateJson.autoReview === 'boolean') { effectiveAutoReview = stateJson.autoReview autoReviewSource = 'issue' @@ -1212,14 +1111,7 @@ class WebhookCoordinator { // 读失败时不知道旧值,也清一次,避免卡片继续挂着已通过。 if (stateJson === undefined || stateJson.reviewPassed === true) { try { - await mergeStateJsonComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber: event.issueNumber, - extra: { reviewPassed: false }, - }) + await mergeForgeState(ctx.provider, event.issueNumber, { reviewPassed: false }) this.activePanel?.postMessage({ type: 'issue/patch', issueNumber: event.issueNumber, @@ -1266,7 +1158,7 @@ class WebhookCoordinator { // 类型上,主动查一次 API 取 merged 状态(成本低,避免 server.ts 解析变动)。 if (!this.ctx) return - const ctx = await this.resolveRepoContext(event.issueNumber) + const ctx = await this.forgeForEvent(event.issueNumber) if (!ctx) return const prIndex = Number.parseInt(event.pr, 10) @@ -1276,15 +1168,9 @@ class WebhookCoordinator { let merged = false let mergedAt: string | null = null try { - const pr = await getPullRequest({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - index: prIndex, - }) + const pr = await ctx.provider.getPullRequest(prIndex) merged = pr.merged - mergedAt = pr.merged_at ?? null + mergedAt = pr.mergedAt ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1311,14 +1197,7 @@ class WebhookCoordinator { } try { - await mergeStateJsonComment({ - host: ctx.host, - owner: ctx.owner, - repo: ctx.repo, - token: ctx.token, - issueNumber: event.issueNumber, - extra: { prMerged: true, prMergedAt: mergedAt ?? new Date().toISOString() }, - }) + await mergeForgeState(ctx.provider, event.issueNumber, { prMerged: true, prMergedAt: mergedAt ?? new Date().toISOString() }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1362,7 +1241,7 @@ class WebhookCoordinator { message: `PR #${event.pr} deleted, 清空 state JSON 中的 pr`, details: `issue=#${event.issueNumber}`, }) - const ctx = await this.resolveRepoContext(event.issueNumber) + const ctx = await this.forgeForEvent(event.issueNumber) if (!ctx) { logger.add({ level: 'error', @@ -1382,14 +1261,7 @@ class WebhookCoordinator { return } try { - await mergeStateJsonComment({ - host: ctx.host, - owner: ctx.owner, - repo: ctx.repo, - token: ctx.token, - issueNumber: event.issueNumber, - extra: { pr: '' }, - }) + await mergeForgeState(ctx.provider, event.issueNumber, { pr: '' }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -1428,28 +1300,6 @@ class WebhookCoordinator { * column transition. */ // kept for future "完成" column trigger - private async deleteWebhookAndForget(issueNumber: number, pending: PendingHook, token: string): Promise { - try { - await deleteWebhook({ - host: pending.host, - token, - owner: pending.owner, - repo: pending.repo, - hookId: pending.hookId, - }) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.add({ - level: 'warn', - source: 'webhook', - message: '删除 gitea hook 失败', - details: message, - }) - } - await this.removePending(issueNumber) - } - /** * Inbound `issue_comment created` handler — picks up the marker comment that * the codex review run posts back, strips the marker line, and injects the @@ -1469,16 +1319,10 @@ class WebhookCoordinator { const seen = this.handoffRequestSeen.get(event.issueNumber) ?? '' if (from === seen || !this.ctx) return - const ws = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!ws) + const resolved = await resolveForge(this.ctx) + if (!resolved.ok) return - const remote = await detectRepo(ws) - if (!remote) - return - const token = await getToken(this.ctx, remote.host) - if (!token) - return - const me = await loginForToken(remote.host, token) + const me = await loginFor(resolved.forge) const payload = event.raw as { issue?: { assignees?: Array<{ login?: string } | null> | null } | null } | null const assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) if (!me || assignees.length === 0) @@ -1527,7 +1371,7 @@ class WebhookCoordinator { details: body.slice(0, 200), }) - const ctx = await this.resolveRepoContext(event.issueNumber) + const ctx = await this.forgeForEvent(event.issueNumber) if (!ctx) { logger.add({ level: 'error', @@ -1543,13 +1387,7 @@ class WebhookCoordinator { let realIssueNumber = event.issueNumber if (event.prNumber !== undefined) { try { - const pr = await getPullRequest({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - index: event.prNumber, - }) + const pr = await ctx.provider.getPullRequest(event.prNumber) const closing = parseClosingIssueNumber(pr.body ?? '') if (closing !== undefined) { realIssueNumber = closing @@ -1604,7 +1442,7 @@ class WebhookCoordinator { // PR 评论(代码审查、PR 讨论、Review 机器人等)绝对不发通知。 const isPureIssueComment = event.prNumber === undefined const author = event.commentAuthor - const me = await loginForToken(ctx.host, ctx.token) + const me = await loginFor(ctx) const isSystemComment = !!systemCommentText(body) const isMine = !!(me && author === me) @@ -1680,7 +1518,7 @@ class WebhookCoordinator { private async triggerReview( issueNumber: number, prNumber: string, - ctx: { host: string, owner: string, repo: string, token: string }, + ctx: ForgeContext, ): Promise { if (!this.ctx) return @@ -1705,7 +1543,7 @@ class WebhookCoordinator { private async triggerReviewBody( issueNumber: number, prNumber: string, - ctx: { host: string, owner: string, repo: string, token: string }, + ctx: ForgeContext, ): Promise { if (!this.ctx) return @@ -1714,23 +1552,9 @@ class WebhookCoordinator { // auto-advance to 'review' so the kanban reflects "审查中" status. Skip if // already in review/done to avoid bouncing the card around. try { - const stateJson = await readStateJsonComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber, - }) + const stateJson = await readForgeState(ctx.provider, issueNumber) if (stateJson.column === 'in-progress') { - const result = await mergeStateJsonCommentGuarded({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber, - protectDoneColumn: true, - extra: { column: 'review' }, - }) + const result = await mergeForgeStateGuarded(ctx.provider, issueNumber, { column: 'review' }, { protectDoneColumn: true }) if (result.protectedDoneColumn) { logger.add({ level: 'info', @@ -1787,13 +1611,7 @@ class WebhookCoordinator { // 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。 let state: Record = {} try { - state = await readStateJsonComment({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - issueNumber, - }) + state = await readForgeState(ctx.provider, issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/vscode/src/youtrack/api.ts b/vscode/src/youtrack/api.ts index 403f4d3..47a3000 100644 --- a/vscode/src/youtrack/api.ts +++ b/vscode/src/youtrack/api.ts @@ -1,11 +1,11 @@ /** * Fetch-based YouTrack REST client. * - * Mirrors the role of `src/gitea/api.ts` for the second issue source. All + * Mirrors the role of the [Gitea provider](vscode/src/forge/gitea/provider.ts) for the second issue source. All * requests authenticate with a YouTrack permanent token via * `Authorization: Bearer `. Non-2xx responses surface as * `YouTrackApiError` so callers can distinguish 401 (token invalid) from other - * failures, matching the Gitea client's contract. + * failures, matching the forge clients' contract. */ export class YouTrackApiError extends Error { diff --git a/vscode/src/youtrack/issueLoader.ts b/vscode/src/youtrack/issueLoader.ts index 6aba48c..9a6f83b 100644 --- a/vscode/src/youtrack/issueLoader.ts +++ b/vscode/src/youtrack/issueLoader.ts @@ -1,13 +1,13 @@ /** * Loads YouTrack-sourced issues into the panel's `Issue` shape — the YouTrack - * counterpart of `src/gitea/issueLoader.ts`. + * counterpart of the [forge issue loader](vscode/src/forge/issueLoader.ts). * * Returns `[]` (not an error) when YouTrack isn't configured, so the panel can - * always call this alongside the Gitea loader and just merge the results. + * always call this alongside the forge loader and just merge the results. */ import type { ExtensionContext } from 'vscode' -import type { Issue } from '../gitea/types' +import type { Issue } from '../issues/types' import type { YouTrackIssue } from './api' import { workspace } from 'vscode' import { getYouTrackToken } from '../auth/secrets' diff --git a/vscode/test/gitea/issueComment.test.ts b/vscode/test/forge/issueComment.test.ts similarity index 99% rename from vscode/test/gitea/issueComment.test.ts rename to vscode/test/forge/issueComment.test.ts index cbfc5f2..24ae5d5 100644 --- a/vscode/test/gitea/issueComment.test.ts +++ b/vscode/test/forge/issueComment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cleanCommentSummary, systemCommentText } from '../../src/gitea/issueComment' +import { cleanCommentSummary, systemCommentText } from '../../src/forge/issueComment' describe('cleanCommentSummary', () => { it('保留普通单行纯文本', () => { diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/forge/state.test.ts similarity index 79% rename from vscode/test/gitea/stateJson.test.ts rename to vscode/test/forge/state.test.ts index dc13996..51d3e0a 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/forge/state.test.ts @@ -1,3 +1,4 @@ +import type { ForgeProvider } from '../../src/forge/types' import { Buffer } from 'node:buffer' import { EventEmitter } from 'node:events' import { delimiter } from 'node:path' @@ -61,36 +62,24 @@ vi.mock('../../src/cc/claudeBin', () => ({ const listIssueComments = vi.fn() const postIssueComment = vi.fn() +const provider = { listIssueComments, postIssueComment } as unknown as ForgeProvider -vi.mock('../../src/gitea/api', () => ({ - listIssueComments, - postIssueComment, -})) - -describe('mergeStateJsonCommentGuarded', () => { +describe('mergeForgeStateGuarded', () => { beforeEach(() => { listIssueComments.mockReset() postIssueComment.mockReset() }) it('keeps done column when an automatic flow tries to merge review while preserving other fields', async () => { - const { mergeStateJsonCommentGuarded } = await import('../../src/gitea/stateJson.js') + const { mergeForgeStateGuarded } = await import('../../src/forge/state.js') listIssueComments.mockResolvedValue([ { body: JSON.stringify({ column: 'done', implementStatus: 'done', prMerged: true }) }, ]) - await mergeStateJsonCommentGuarded({ - host: 'https://gitea.example', - owner: 'owner', - repo: 'repo', - token: 'token', - issueNumber: 123, - protectDoneColumn: true, - extra: { column: 'review', reviewSessionId: 'review-1' }, - }) + await mergeForgeStateGuarded(provider, 123, { column: 'review', reviewSessionId: 'review-1' }, { protectDoneColumn: true }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ column: 'done', implementStatus: 'done', prMerged: true, @@ -99,20 +88,12 @@ describe('mergeStateJsonCommentGuarded', () => { }) it('skips posting when protected done would only merge a stale column', async () => { - const { mergeStateJsonCommentGuarded } = await import('../../src/gitea/stateJson.js') + const { mergeForgeStateGuarded } = await import('../../src/forge/state.js') listIssueComments.mockResolvedValue([ { body: JSON.stringify({ column: 'done', implementStatus: 'done' }) }, ]) - await mergeStateJsonCommentGuarded({ - host: 'https://gitea.example', - owner: 'owner', - repo: 'repo', - token: 'token', - issueNumber: 123, - protectDoneColumn: true, - extra: { column: 'review' }, - }) + await mergeForgeStateGuarded(provider, 123, { column: 'review' }, { protectDoneColumn: true }) expect(postIssueComment).not.toHaveBeenCalled() }) @@ -125,7 +106,7 @@ describe('state JSON 尾部普通评论不丢状态', () => { }) it('merge 时跳过尾部普通文本评论,保留完整历史 state', async () => { - const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { mergeForgeState } = await import('../../src/forge/state.js') const fullState = { color: 'terminal.ansiCyan', column: 'in-progress', @@ -143,21 +124,14 @@ describe('state JSON 尾部普通评论不丢状态', () => { { body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) }, ] listIssueComments.mockImplementation(async () => [...comments]) - postIssueComment.mockImplementation(async (opts: { body: string }) => { - comments.push({ body: opts.body }) + postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => { + comments.push({ body }) }) - await mergeStateJsonComment({ - host: 'https://gitea.example', - owner: 'owner', - repo: 'repo', - token: 'token', - issueNumber: 182, - extra: { pr: '182', implementStatus: 'done' }, - }) + await mergeForgeState(provider, 182, { pr: '182', implementStatus: 'done' }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ ...fullState, pr: '182', implementStatus: 'done', @@ -165,53 +139,40 @@ describe('state JSON 尾部普通评论不丢状态', () => { }) it('post 后读回发现字段被并发覆盖时,在最新状态上重放一次', async () => { - const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { mergeForgeState } = await import('../../src/forge/state.js') const comments: Array<{ body: string }> = [ { body: JSON.stringify({ column: 'in-progress' }) }, ] listIssueComments.mockImplementation(async () => [...comments]) let postCount = 0 - postIssueComment.mockImplementation(async (opts: { body: string }) => { + postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => { postCount++ if (postCount === 1) { // 模拟并发写者在我们 post 之后又盖了一条:我们的字段丢失。 comments.push({ body: JSON.stringify({ column: 'review' }) }) return } - comments.push({ body: opts.body }) + comments.push({ body }) }) - await mergeStateJsonComment({ - host: 'https://gitea.example', - owner: 'owner', - repo: 'repo', - token: 'token', - issueNumber: 7, - extra: { pr: '99' }, - }) + await mergeForgeState(provider, 7, { pr: '99' }) expect(postIssueComment).toHaveBeenCalledTimes(2) // 重放基于并发写者的最新状态,两边的字段都保留。 - expect(parseStateComment(postIssueComment.mock.calls[1][0].body)).toEqual({ + expect(parseStateComment(postIssueComment.mock.calls[1][1])).toEqual({ column: 'review', pr: '99', }) }) it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => { - const { readStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { readForgeState } = await import('../../src/forge/state.js') listIssueComments.mockResolvedValue([ { body: JSON.stringify({ column: 'review', pr: '9' }) }, { body: 'PR 已关联(Gitea 自动评论)' }, ]) - const state = await readStateJsonComment({ - host: 'https://gitea.example', - owner: 'owner', - repo: 'repo', - token: 'token', - issueNumber: 9, - }) + const state = await readForgeState(provider, 9) expect(state).toEqual({ column: 'review', pr: '9' }) }) @@ -227,30 +188,30 @@ describe('state defaults', () => { [{}, { column: 'todo', color: 'blue', pr: '5' }], [{ column: 'review', color: 'red' }, { column: 'review', color: 'red', pr: '5' }], ])('fills missing defaults without replacing existing values: %j', async (existing, expected) => { - const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { mergeForgeState } = await import('../../src/forge/state.js') const comments = [{ body: JSON.stringify(existing) }] listIssueComments.mockImplementation(async () => [...comments]) - postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) - await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { pr: '5' } }) - expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual(expected) + postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => comments.push({ body })) + await mergeForgeState(provider, 5, { pr: '5' }, { column: 'todo', color: 'blue' }) + expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual(expected) expect(postIssueComment).toHaveBeenCalledOnce() }) it('explicit changes override defaults and existing state', async () => { - const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { mergeForgeState } = await import('../../src/forge/state.js') const comments = [{ body: JSON.stringify({ column: 'review' }) }] listIssueComments.mockImplementation(async () => [...comments]) - postIssueComment.mockImplementation(async ({ body }: { body: string }) => comments.push({ body })) - await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: { column: 'done' } }) - expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'done', color: 'blue' }) + postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => comments.push({ body })) + await mergeForgeState(provider, 5, { column: 'done' }, { column: 'todo', color: 'blue' }) + expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ column: 'done', color: 'blue' }) }) it('read-back does not force defaults over a concurrent update', async () => { - const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js') + const { mergeForgeState } = await import('../../src/forge/state.js') listIssueComments.mockResolvedValueOnce([]).mockResolvedValue([{ body: JSON.stringify({ column: 'review', color: 'red' }) }]) - await mergeStateJsonComment({ host: 'host', owner: 'owner', repo: 'repo', token: 'token', issueNumber: 5, defaults: { column: 'todo', color: 'blue' }, extra: {} }) + await mergeForgeState(provider, 5, {}, { column: 'todo', color: 'blue' }) expect(postIssueComment).toHaveBeenCalledOnce() - expect(parseStateComment(postIssueComment.mock.calls[0][0].body)).toEqual({ column: 'todo', color: 'blue' }) + expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ column: 'todo', color: 'blue' }) }) })