Files
superwork/vscode/src/panel/handlers/release.ts
T

246 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工具栏「发布」按钮的扩展侧 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<void> {
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<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 { 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<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,
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<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)
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<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)
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<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)
})
}