/** * 工具栏「发布」按钮的扩展侧 handler:切新发布分支、摘修复到当期发布分支、触发正式服部署。 * * git 逻辑在 `src/git/release.ts`,Jenkins 触发在 `src/jenkins/trigger.ts`;这里只负责 * 读设置、弹 QuickPick、发 toast、维护 `panel.releaseRunning` 互斥。 */ import type { CherryPickConflict } from '../../git/release' import type { KanbanWebviewPanel } from '../KanbanPanel' import { window, workspace } from 'vscode' import { buildCcCommand } from '../../cc/ccCommand' import { resolveProfilePath } from '../../cc/profiles' import { gitFetch } from '../../git/branches' import { attachPulls, cherryPickToRelease, cutRelease, getReleaseStatus, groupCommitsByPr, indexUnreleasedByMergePr, listReleaseBranches, listUnreleasedCommits, nextReleaseBranch, } from '../../git/release' import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir } from '../../git/worktreePath' import { triggerJenkinsBuild } from '../../jenkins/trigger' import { logger } from '../../logging/logger' import { getSettings } from '../../settings/store' import { dismiss, toast } from './handoffFlow' import { submitToSessionTerminal } from './terminals' export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise { const root = workspace.workspaceFolders?.[0]?.uri.fsPath const s = getSettings(panel.context) const base = { type: 'release/status' as const, devBranch: s.devBranch, nextBranch: nextReleaseBranch(s.releaseBranchPrefix, []), running: panel.releaseRunning, jenkinsConfigured: s.jenkinsJob.length > 0, } if (!root) { panel.postMessage({ ...base, unreleased: 0, unavailable: '请先打开一个工作区文件夹' }) return } try { const st = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) panel.postMessage({ ...base, nextBranch: st.next, current: st.current, unreleased: st.unreleased, unavailable: st.unavailable }) } catch (err) { const message = err instanceof Error ? err.message : String(err) panel.postMessage({ ...base, unreleased: 0, unavailable: message }) } } function requireRoot(): string { const root = workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error('请先打开一个工作区文件夹') return root } /** 三个写动作共用:互斥 + 前后刷新状态 + 统一错误 toast */ async function runReleaseAction(panel: KanbanWebviewPanel, fn: () => Promise): Promise { if (panel.releaseRunning) return panel.releaseRunning = true void handleReleaseStatus(panel) try { await fn() } catch (err) { toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) } finally { panel.releaseRunning = false void handleReleaseStatus(panel) } } async function deploy(panel: KanbanWebviewPanel, branch: string): Promise { const s = getSettings(panel.context) await triggerJenkinsBuild({ job: s.jenkinsJob, params: { REF: branch, REMOTE_HOST: s.jenkinsProdHost } }) toast(panel, 'success', `已触发 Jenkins ${s.jenkinsJob}:REF=${branch} → ${s.jenkinsProdHost}`) } export function handleReleaseCut(panel: KanbanWebviewPanel): Promise { return runReleaseAction(panel, async () => { const root = requireRoot() const s = getSettings(panel.context) const { branch, count } = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) toast(panel, 'success', `已切发布分支 ${branch}(${count} 个提交)`) if (!s.jenkinsJob) return const pick = await window.showInformationMessage(`现在部署 ${branch} 到${s.jenkinsProdHost}?`, '部署') if (pick === '部署') await deploy(panel, branch) }) } export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise { try { const root = requireRoot() const s = getSettings(panel.context) const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) if (status.unavailable) throw new Error(status.unavailable) const current = status.current if (!current) throw new Error('尚无发布分支,先切新发布') const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current }) if (commits.length === 0) { toast(panel, 'info', '没有待摘的提交') return } const index = await indexUnreleasedByMergePr({ cwd: root, current, devBranch: s.devBranch, shas: commits.map(c => c.sha), }) panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(attachPulls(commits, index)), firstAfterReleaseSha: commits.at(-1)?.sha, }) } catch (err) { toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) } } export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: string[]): Promise { return runReleaseAction(panel, async () => { if (shas.length === 0) return const root = requireRoot() const s = getSettings(panel.context) const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) if (status.unavailable) throw new Error(status.unavailable) const current = status.current if (!current) throw new Error('尚无发布分支,先切新发布') const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current }) const wanted = new Set(shas) const blocked = commits.find(c => wanted.has(c.sha) && c.migration?.blocked) if (blocked) throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`) const order = new Map(commits.map((c, i) => [c.sha, i])) const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0)) // 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。 const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, root, current.replace(/\//g, '-')) const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 }) let conflict: CherryPickConflict | undefined try { conflict = await cherryPickToRelease({ cwd: root, branch: current, shas: ordered, worktreePath }) } finally { dismiss(panel, spinner) } if (!conflict) { toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`) return } openReleaseConflictSession(panel, { branch: current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) }) toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${current} 冲突解决会话接手`, { dismissOnTimer: 10_000 }) }) } /** * 摘修复冲突后的接力:worktree 里 cherry-pick 停在中间态,开一个 cc 会话进去解决、 * 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。 */ function openReleaseConflictSession(panel: KanbanWebviewPanel, o: { branch: string, conflict: CherryPickConflict, subjectOf: Map }): void { const { branch, conflict } = o const remaining = conflict.remaining.length === 0 ? ' (没有剩余提交)' : conflict.remaining.map(sha => ` - ${sha} ${o.subjectOf.get(sha) ?? ''}`.trimEnd()).join('\n') const prompt = `当前目录是发布分支 ${branch} 的 worktree(detached HEAD),执行 \`git cherry-pick -x ${conflict.sha}\`(${conflict.subject})时出现冲突,冲突已落地。你的任务: 1. 跑 \`git status\` 查看冲突文件 2. 逐个解决冲突,保留这个提交的改动意图 3. \`git add\` 已解决的文件,然后 \`git cherry-pick --continue\`(保留默认提交说明) 4. 按下面的顺序继续 \`git cherry-pick -x \`,再冲突就同样处理: ${remaining} 5. 全部完成后 \`git push origin HEAD:refs/heads/${branch}\` 注意: - 不要 rebase、不要 force push、不要碰其他分支 - 全部推完后告诉用户「${branch} 已摘完并推送」` const terminalName = `${branch}-冲突解决` const live = panel.findExistingTerminal(terminalName) if (live) { live.show(false) submitToSessionTerminal(live, prompt) logger.add({ level: 'info', source: 'panel', message: `摘修复冲突:已把提示词打进 ${terminalName}` }) return } const profilePath = resolveProfilePath(getSettings(panel.context).conflictResolutionProfilePath.trim() || undefined) if (profilePath.includes('\'')) { toast(panel, 'error', `冲突解决失败:profilePath 含单引号 (${profilePath})`, { dismissOnTimer: 10_000 }) return } const terminal = window.createTerminal({ name: terminalName, cwd: conflict.worktreePath, location: panel.resolveTerminalLocation(false), }) terminal.show(false) // buildCcCommand 用单引号包提示词,内部的 ' 要转成 '\'' 才不会破壳。 terminal.sendText(buildCcCommand({ profilePath, effort: 'high', prompt: prompt.replace(/'/g, '\'\\\'\'') })) logger.add({ level: 'info', source: 'panel', message: `摘修复冲突:cc 会话已启动 (${branch}, ${conflict.sha.slice(0, 7)}, cwd ${conflict.worktreePath}, profile ${profilePath})`, }) } export function handleReleaseDeploy(panel: KanbanWebviewPanel): Promise { return runReleaseAction(panel, async () => { const root = requireRoot() const s = getSettings(panel.context) if (!s.jenkinsJob) throw new Error('未配置 Jenkins 任务名,请先在设置里填写') await gitFetch(root) const branches = await listReleaseBranches({ cwd: root, prefix: s.releaseBranchPrefix }) if (branches.length === 0) throw new Error('尚无发布分支') const [current, ...older] = branches const items = [ { label: current, description: '当期' }, ...older.map(label => ({ label, description: '回滚到此发布点' })), ] const pick = await window.showQuickPick(items, { title: `部署到${s.jenkinsProdHost}`, placeHolder: '选择要部署的发布分支(默认当期,选旧的即回滚)', }) if (!pick) return await deploy(panel, pick.label) }) }