diff --git a/docs/superpowers/specs/release-control/spec.md b/docs/superpowers/specs/release-control/spec.md new file mode 100644 index 0000000..10aed89 --- /dev/null +++ b/docs/superpowers/specs/release-control/spec.md @@ -0,0 +1,154 @@ +# 工具栏「发布」按钮 — 设计 + +## 背景 + +发布流程(`luffy-agent-platform/docs/release-process.md`)已改为:main 只往前走;线上跑的是某个 `release/YYYY-MM-DD` 发布分支;发什么由 Jenkins 的 `REF` 参数决定;线上紧急修复是「先进 main,再 cherry-pick 到当期发布分支」;回滚是拿旧发布分支再发一次。 + +扩展现有的「分支同步」按钮建模的是「把远端 `autoBuildBranch`(`jenkins` 分支)快进到 `devBranch`」。`jenkins` 分支即将删除,这个模型已经无法表达任何东西:按钮永远显示「开发分支与自动化构建分支相同」。 + +本设计用「发布点管理」替换它。不保留旧功能、不做兼容。 + +## 目标 + +工具栏一个「发布」按钮,做到三件事,与发布文档的三个步骤一一对应: + +1. **切新发布**:从 `origin/` 切当天的 `release/YYYY-MM-DD` 并推远端。 +2. **摘修复到当期发布**:把 main 上还没进发布分支的提交挑选后 cherry-pick 到当期发布分支并推远端。 +3. **部署到正式服**:选一个发布分支(默认当期;选旧的即回滚)触发 Jenkins 构建。 + +并随时显示:当期发布点是哪个、`devBranch` 上还有多少提交没发出去。 + +## 非目标 + +- 打 tag、删旧发布分支、测试服部署(推 main 已自动触发)、查询 Jenkins 构建结果。 +- 本地分支操作:所有 git 写操作只碰远端引用和临时 worktree,不改用户当前工作区的分支与文件。 + +## 术语 + +| 词 | 含义 | +|---|---| +| `devBranch` | 日常开发分支,现有设置项,默认 `main` | +| `releaseBranchPrefix` | 发布分支前缀,新设置项,默认 `release/` | +| 当期发布分支 | 远端 `*` 里名字排序最大的那个(名字是 ISO 日期,字典序即时间序) | +| 未发布提交 | `origin/<当期发布分支>..origin/` 中、按补丁内容去重后仍在的提交(`git rev-list --right-only --cherry-pick --no-merges`),被 cherry-pick 过的不算 | + +## 设置 + +`src/settings/store.ts`: + +- 删除 `autoBuildBranch`(类型、默认值、读取归一化、设置面板输入框、消息类型、README)。 +- 新增 `releaseBranchPrefix: string`,默认 `release/`;trim 后为空回退默认;不以 `/` 结尾时读取时补上。 +- 新增 `jenkinsJob: string`,默认 `''`;空 = 不提供「部署到正式服」动作。 +- 新增 `jenkinsProdHost: string`,默认 `正式服`;即 Jenkins `REMOTE_HOST` 参数的取值。 + +设置面板「认证」分组里,`autoBuildBranch` 输入框原位换成这三个字段(`releaseBranchPrefix`、`jenkinsJob`、`jenkinsProdHost`)。 + +## 模块划分 + +### `src/git/release.ts`(新,纯 git,可测) + +不依赖 VS Code API。所有命令用 `execFile('git', ['-C', cwd, ...])`,30s 超时。 + +```ts +export interface ReleaseStatus { + devBranch: string + /** 当期发布分支全名(含前缀);远端没有任何发布分支时为 undefined */ + current: string | undefined + /** 未发布提交数;current 为 undefined 时为 0,由 UI 显示「尚无发布点」 */ + unreleased: number + /** 结构性不可用(不是 git 仓库 / fetch 失败 / devBranch 远端不存在),带原因 */ + unavailable?: string +} + +export function todayReleaseBranch(prefix: string, now?: Date): string // 'release/2026-08-31' +export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined +export async function getReleaseStatus(o: { cwd, devBranch, prefix }): Promise +export async function listUnreleasedCommits(o: { cwd, devBranch, current }): Promise> +export async function listReleaseBranches(o: { cwd, prefix }): Promise // 新→旧 +export async function cutRelease(o: { cwd, devBranch, branch }): Promise +export async function cherryPickToRelease(o: { cwd, branch, shas: string[] }): Promise +``` + +细节: + +- `getReleaseStatus` 先 `git fetch origin --prune`,再 `git for-each-ref refs/remotes/origin/` 取分支列表,`unreleased` 用 `git rev-list --count --right-only --cherry-pick --no-merges origin/...origin/`。 +- `cutRelease`:先确认 `origin/` 不存在(存在则抛「今天的发布分支已存在」),然后 `git push origin origin/:refs/heads/`。纯远端,不建本地分支。 +- `cherryPickToRelease`:`git worktree add --detach origin/` → 在 tmp 里按 shas 给定顺序(旧→新)`git cherry-pick -x ...` → `git push origin HEAD:refs/heads/` → `git worktree remove --force `。任一步失败:cherry-pick 冲突先 `git cherry-pick --abort`,无论如何 `finally` 里删 worktree,抛出的错误里带上冲突的 sha 与 subject。tmp 目录在 `os.tmpdir()` 下,名字带随机后缀。`-x` 让发布分支上的提交留下「cherry picked from」痕迹,便于追溯。 +- `listUnreleasedCommits`:`git log --right-only --cherry-pick --no-merges --format=%x00%x00%x00 origin/...origin/`,默认 git log 顺序(新→旧)。 + +`pickCurrentRelease` 与 `todayReleaseBranch` 是纯函数,单测覆盖:前缀过滤、字典序取最大、空列表、非日期名混入(只按字典序,不解析日期)。 + +### `src/jenkins/trigger.ts`(新) + +```ts +export async function triggerJenkinsBuild(o: { job: string, params: Record }): Promise +``` + +执行 `opencli jks build -p K=V -p K=V`(每个参数一个 `-p`),不加 `-s`(文档明确:同步等待时命令行一断 Jenkins 会中止构建)。60s 超时。`opencli` 不在 PATH → 抛「未找到 opencli」;退出码非零 → 抛并带 stderr 尾部。 + +### `src/panel/handlers/release.ts`(新,替换 toolbar.ts 里的 branchSync 两个 handler) + +- `handleReleaseStatus(panel)`:读设置 → `getReleaseStatus` → `postMessage({ type: 'release/status', ... })`。触发时机与旧 `branch-sync/check` 完全相同(面板就绪、用户点刷新、每个写动作结束后)。 +- `handleReleaseCut(panel)`:`cutRelease` 今天的分支 → 成功 toast `已切发布分支 release/…(N 个提交)`;若 `jenkinsJob` 非空,toast 之后紧接 `window.showInformationMessage('现在部署 release/… 到正式服?', '部署')`,点了就走部署。 +- `handleReleaseCherryPick(panel)`:无当期发布分支 → error toast「尚无发布分支,先切新发布」。`listUnreleasedCommits` 为空 → info toast「没有待摘的提交」。否则 VS Code QuickPick 多选(label = subject,description = 短 sha · 作者 · 日期),确认后按 git 时间顺序(旧→新)cherry-pick;进行中 spinner toast,成功 toast `已摘 N 个提交到 release/…`。失败 error toast 带冲突 sha。 +- `handleReleaseDeploy(panel)`:`listReleaseBranches` → QuickPick 单选,默认选中当期,其它项 description 标「回滚到此发布点」→ `triggerJenkinsBuild({ job, params: { REF: branch, REMOTE_HOST: jenkinsProdHost } })` → 成功 toast `已触发 Jenkins ${job}:REF=${branch} → ${jenkinsProdHost}`。 + +每个写动作 `finally` 都重新 `handleReleaseStatus`。三个动作共用一个 `panel.releaseRunning` 互斥标志(与 `commitRunning` 同模式),运行中按钮禁用。 + +### 消息(`src/panel/messages.ts` / `webview-ui/src/lib/messages.ts`) + +删 `branch-sync/status | check | run`。新增: + +```ts +// 扩展 → webview +| { type: 'release/status', devBranch: string, current?: string, unreleased: number, unavailable?: string, running: boolean } +// webview → 扩展 +| { type: 'release/check' } +| { type: 'release/cut' } +| { type: 'release/cherry-pick' } +| { type: 'release/deploy' } +``` + +`release/status` 里带 `running`,webview 不再自己维护 running 标志(旧实现里 webview 自置 running 再等 status 清除,两头各维护一份状态是多余的)。 + +### Webview + +- `PanelHeader.tsx`:旧同步按钮换成「发布」按钮,图标 `Rocket`(lucide);`unreleased > 0` 时右上角小圆角数字角标(≥100 显示 `99+`);`running` 时转圈;`unavailable` 时禁用。 +- 悬停文案: + - unavailable → 原因 + - 无 current → `尚无发布点 · 点击切第一个发布分支` + - 有 current → `正式服发布点 ${current} · ${devBranch} 领先 ${unreleased} 个提交`(0 时写「已全部发布」) +- 点击弹出下拉菜单(与工具栏其它按钮风格一致,`absolute` 定位,点外面关): + - `切新发布(release/今天)` + - `摘修复到 ${current}`(无 current 时禁用) + - `部署到正式服…`(`jenkinsJob` 为空时不渲染此项) + + 菜单项点击发对应消息后关闭。所有多选/单选在扩展侧用 VS Code QuickPick 完成,webview 不做列表 UI。 +- `useIssues.ts`:删 `branchSync*` 五个状态与 `runBranchSync`,换成 `release: ReleaseStatusMsg | undefined` 与 `runRelease(action: 'cut' | 'cherry-pick' | 'deploy')`。`App.tsx` 三处透传随之更新。 + +### 删除 + +- `src/git/branchSync.ts` 里的 `checkBranchSync`、`runBranchSync`、`BranchSyncStatus`、`CheckBranchSyncOpts`、`RunBranchSyncOpts` 及对应测试。留下的 `runGit`、`gitFetch`、`resolveFeatureBranch`、`deleteLocalBranch`、`pruneRemoteTrackingRef` 被 issues / worktree / branchCleanup 使用,整体改名为 `src/git/branches.ts`(连同测试文件),更新 4 处 import。 +- `toolbar.ts` 里 `handleBranchSyncCheck` / `handleBranchSyncRun`、`KanbanPanel.ts` 的 `handleBranchSyncCheck` 方法与消息分发。 +- README:工具栏一句里「分支同步(…)」换成「发布(切发布分支 · 摘修复 · 触发正式服部署)」;设置表里 `autoBuildBranch` 行换成三个新设置项;工作流章节末尾补一小节「发布」三步。 + +## 错误处理 + +| 情况 | 表现 | +|---|---| +| 不是 git 仓库 / 没有 origin / fetch 失败 | 按钮禁用,悬停显示原因 | +| `origin/` 不存在 | 同上,原因「远端没有 main」 | +| 当天发布分支已存在 | error toast,不推送 | +| cherry-pick 冲突 | abort、删临时 worktree、error toast「 冲突,请手动处理」,远端不变 | +| 推送被拒(远端分支保护 / 并发推送) | error toast 带 git stderr 尾部 | +| `opencli` 缺失或 Jenkins 拒绝 | error toast 带原因;git 侧已完成的动作不回滚(发布分支切好了就是切好了) | + +## 测试 + +- `src/git/release.test.ts`:`todayReleaseBranch`(前缀补 `/`、日期格式)、`pickCurrentRelease`(过滤、排序、空)纯函数单测;`getReleaseStatus` / `cutRelease` / `cherryPickToRelease` 用 `vitest` 在临时目录里真建 bare 仓库当 origin 做集成测试(仓库里 `test/git/` 已有类似做法):切分支、cherry-pick 干净、cherry-pick 冲突后远端不变且 worktree 被清理、已 cherry-pick 的提交不再计入 unreleased。 +- `src/jenkins/trigger.test.ts`:参数拼装(每个 `-p` 一对、不含 `-s`);用注入的 exec 函数,不真跑 opencli。 +- 手动验收:在 luffy-agent-platform 工作区看角标数字 = `git rev-list --count --right-only --cherry-pick origin/release/2026-08-31...origin/main`。 + +## 收尾 + +按 `vscode/CLAUDE.md`:版本 patch +1、打 VSIX、装两个 profile、提交。