♻️ refactor(vscode): 代码托管仓库抽成统一接口,Gitea 实现搬到 provider 后面,翻页改看 Link 头

This commit is contained in:
cruldra committed 2026-10-19 07:56:40 +11:00
1 parent c90d84d7f2
commit 14bee9f505
64 files changed
+1881 -2718

No files matched your search

+6 -7
View File
@@ -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<void> {
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) {
+23
View File
@@ -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<ForgeContext | undefined> {
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<ForgeContext | undefined> {
const resolved = await resolveForge(panel.context)
if (resolved.ok)
return resolved.forge
void window.showErrorMessage(resolved.message)
return undefined
}
+1 -1
View File
@@ -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'
+22 -39
View File
@@ -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<RepoCtx | undefined> {
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<string, unknown>
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<string, unknown>
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
+9 -23
View File
@@ -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) {
File diff suppressed because it is too large. Load diff
+50 -66
View File
@@ -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<number | undefined> {
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<number | undefined> {
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<void> {
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<void> {
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<void> {
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)
+1 -2
View File
@@ -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(),
+20 -77
View File
@@ -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<void> {
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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+7 -15
View File
@@ -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<void> {
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 : ''
+1 -1
View File
@@ -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'