268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
/**
|
||
* 工具栏「发布」按钮的扩展侧 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 { buildCcArgv } from '../../cc/ccCommand'
|
||
import { resolveProfilePath } from '../../cc/profiles'
|
||
import { sessionLaunch } from '../../cc/sessionTerminal'
|
||
import { gitFetch } from '../../git/branches'
|
||
import {
|
||
attachPulls,
|
||
cherryPickToRelease,
|
||
cutRelease,
|
||
getReleaseStatus,
|
||
groupCommitsByPr,
|
||
indexUnreleasedByMergePr,
|
||
listReleaseBranches,
|
||
listUnreleasedCommits,
|
||
todayReleaseBranch,
|
||
} 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<void> {
|
||
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
const s = getSettings(panel.context)
|
||
const base = {
|
||
type: 'release/status' as const,
|
||
devBranch: s.devBranch,
|
||
nextBranch: todayReleaseBranch(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, 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<void>): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
return runReleaseAction(panel, async () => {
|
||
const root = requireRoot()
|
||
const s = getSettings(panel.context)
|
||
const r = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
|
||
if (r.kind === 'diverged') {
|
||
const picked = await pickUnreleased(panel, { root, devBranch: s.devBranch, current: r.branch })
|
||
if (picked !== 'done')
|
||
return
|
||
}
|
||
else if (r.kind === 'advanced' && r.count === 0) {
|
||
toast(panel, 'info', `${r.branch} 已经和 ${s.devBranch} 一致,没有新提交`)
|
||
return
|
||
}
|
||
else {
|
||
toast(panel, 'success', r.kind === 'created'
|
||
? `已切发布分支 ${r.branch}(${r.count} 个提交)`
|
||
: `已把 ${s.devBranch} 上 ${r.count} 个新提交推进到 ${r.branch}`)
|
||
}
|
||
if (!s.jenkinsJob)
|
||
return
|
||
const pick = await window.showInformationMessage(`现在部署 ${r.branch} 到${s.jenkinsProdHost}?`, '部署')
|
||
if (pick === '部署')
|
||
await deploy(panel, r.branch)
|
||
})
|
||
}
|
||
|
||
export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void> {
|
||
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,
|
||
commits,
|
||
})
|
||
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<void> {
|
||
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)
|
||
if (!status.current)
|
||
throw new Error('尚无发布分支,先切新发布')
|
||
await pickUnreleased(panel, { root, devBranch: s.devBranch, current: status.current, shas })
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 把 devBranch 上未发布的提交摘到 current,shas 不传就全摘。
|
||
* 冲突时开会话接手并返回 'conflict',调用方据此决定后续(比如要不要问部署)。
|
||
*/
|
||
async function pickUnreleased(panel: KanbanWebviewPanel, o: { root: string, devBranch: string, current: string, shas?: string[] }): Promise<'done' | 'conflict' | 'nothing'> {
|
||
const s = getSettings(panel.context)
|
||
const commits = await listUnreleasedCommits({ cwd: o.root, devBranch: o.devBranch, current: o.current })
|
||
const wanted = o.shas && new Set(o.shas)
|
||
const chosen = wanted ? commits.filter(c => wanted.has(c.sha)) : commits
|
||
if (chosen.length === 0) {
|
||
toast(panel, 'info', '没有待摘的提交')
|
||
return 'nothing'
|
||
}
|
||
const blocked = chosen.find(c => c.migration?.blocked)
|
||
if (blocked)
|
||
throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`)
|
||
const ordered = chosen.map(c => c.sha).reverse() // ← 列表是新→旧,摘要旧→新
|
||
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
|
||
const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, o.root, o.current.replace(/\//g, '-'))
|
||
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${o.current}…`, { spinner: true, dismissOnTimer: 0 })
|
||
let conflict: CherryPickConflict | undefined
|
||
try {
|
||
conflict = await cherryPickToRelease({ cwd: o.root, branch: o.current, shas: ordered, worktreePath })
|
||
}
|
||
finally {
|
||
dismiss(panel, spinner)
|
||
}
|
||
if (!conflict) {
|
||
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${o.current}`)
|
||
return 'done'
|
||
}
|
||
openReleaseConflictSession(panel, { branch: o.current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) })
|
||
toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${o.current} 冲突解决会话接手`, { dismissOnTimer: 10_000 })
|
||
return 'conflict'
|
||
}
|
||
|
||
/**
|
||
* 摘修复冲突后的接力:worktree 里 cherry-pick 停在中间态,开一个 cc 会话进去解决、
|
||
* 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。
|
||
*/
|
||
function openReleaseConflictSession(panel: KanbanWebviewPanel, o: { branch: string, conflict: CherryPickConflict, subjectOf: Map<string, string> }): 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 <sha>\`,再冲突就同样处理:
|
||
${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)
|
||
const terminal = window.createTerminal({
|
||
name: terminalName,
|
||
cwd: conflict.worktreePath,
|
||
location: panel.resolveTerminalLocation(false),
|
||
...sessionLaunch(buildCcArgv({ profilePath, effort: 'high', prompt })),
|
||
})
|
||
terminal.show(false)
|
||
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<void> {
|
||
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)
|
||
})
|
||
}
|