Files
superwork/docs/superpowers/plans/release-control/plan.md
T

395 lines
26 KiB
Markdown
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.
# 工具栏「发布」按钮 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用「发布点管理」按钮(切发布分支 / 摘修复 / 触发正式服部署)替换已失效的「分支同步」按钮。
**Architecture:** 纯 git 逻辑放 `src/git/release.ts`(无 VS Code 依赖,用真实 bare 仓库做集成测试);Jenkins 触发放 `src/jenkins/trigger.ts`(包 `opencli jks build`);面板 handler 放 `src/panel/handlers/release.ts`;webview 侧一个带下拉菜单的按钮,多选/单选交给 VS Code QuickPick。旧 branchSync 功能整体删除,其余通用 git 助手改名 `src/git/branches.ts`。
**Tech Stack:** TypeScript、VS Code Extension API、React webview、vitest、`execFile('git')`。
**Spec:** `docs/superpowers/specs/release-control/spec.md`
## Global Constraints
- 所有工作在 `vscode/` 子目录;命令 `pnpm test`(= `vitest run`)、`pnpm typecheck`、`pnpm lint` 三者必须保持 0 错(lint 当前基线 0/0)。
- 不做兼容:`autoBuildBranch`、`branch-sync/*`、`checkBranchSync`、`runBranchSync` 全部删除,不留别名。
- 代码注释、toast 文案、提交信息用中文;提交信息用仓库 emoji 前缀风格(`✨ feat(vscode): …`、`♻️ refactor(vscode): …`、`📝 docs(vscode): …`),末尾加一行 `Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa`。
- git 写操作只碰远端引用和临时 worktree,绝不 checkout 用户工作区。
- `test/git/worktreeFromRemote.test.ts` 有 1 个既有失败(`/var` vs `/private/var`),与本计划无关,不算回归。
---
### Task 1: `src/git/release.ts` 纯 git 模块
**Files:**
- Create: `vscode/src/git/release.ts`
- Test: `vscode/test/git/release.test.ts`
**Interfaces:**
- Produces:
```ts
export interface ReleaseStatus { devBranch: string, current: string | undefined, unreleased: number, unavailable?: string }
export interface UnreleasedCommit { sha: string, subject: string, author: string, date: string }
export function normalizeReleasePrefix(raw: string): string // trim;空→'release/';无尾 '/' 补上
export function todayReleaseBranch(prefix: string, now?: Date): string // `${prefix}${YYYY-MM-DD}`,本地时区
export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined
export async function getReleaseStatus(o: { cwd: string, devBranch: string, prefix: string }): Promise<ReleaseStatus>
export async function listReleaseBranches(o: { cwd: string, prefix: string }): Promise<string[]> // 新→旧,不 fetch
export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]>
export async function cutRelease(o: { cwd: string, devBranch: string, branch: string }): Promise<number> // 返回该分支相对上一个发布点的提交数(无上一个则为 0)
export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[] }): Promise<void>
export class ReleaseError extends Error { constructor(message: string, public readonly detail?: string) }
```
- [ ] **Step 1: 写失败测试**(纯函数 + 集成)
`test/git/release.test.ts`,fixture 照抄 `test/git/worktreeFromRemote.test.ts` 的 `gitEnv` / `git()` 写法:
```ts
import { execFileSync } from 'node:child_process'
import { promises as fsp, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
cherryPickToRelease, cutRelease, getReleaseStatus, listReleaseBranches,
listUnreleasedCommits, normalizeReleasePrefix, pickCurrentRelease, todayReleaseBranch,
} from '../../src/git/release'
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
const git = (cwd: string, args: string[]) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim()
interface Fx { root: string, origin: string, repo: string, peer: string }
async function setup(): Promise<Fx> {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-release-'))
const origin = path.join(root, 'origin.git')
git(root, ['init', '--bare', '-b', 'main', origin])
const peer = path.join(root, 'peer')
git(root, ['clone', origin, peer])
commit(peer, 'a.txt', 'a', 'init')
git(peer, ['push', '-u', 'origin', 'main'])
const repo = path.join(root, 'repo')
git(root, ['clone', origin, repo])
return { root, origin, repo, peer }
}
function commit(cwd: string, file: string, content: string, msg: string): string {
writeFileSync(path.join(cwd, file), `${content}\n`)
git(cwd, ['add', file]); git(cwd, ['commit', '-m', msg])
return git(cwd, ['rev-parse', 'HEAD'])
}
let fx: Fx | undefined
afterEach(() => { if (fx) rmSync(fx.root, { recursive: true, force: true }); fx = undefined })
describe('纯函数', () => {
it('normalizeReleasePrefix', () => {
expect(normalizeReleasePrefix('')).toBe('release/')
expect(normalizeReleasePrefix(' rel ')).toBe('rel/')
expect(normalizeReleasePrefix('release/')).toBe('release/')
})
it('todayReleaseBranch', () => {
expect(todayReleaseBranch('release/', new Date(2026, 7, 31))).toBe('release/2026-08-31')
})
it('pickCurrentRelease 取前缀内字典序最大', () => {
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
})
})
describe('集成', () => {
it('无发布分支 → current undefined,unreleased 0', async () => {
fx = await setup()
const s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' })
expect(s).toMatchObject({ devBranch: 'main', current: undefined, unreleased: 0 })
expect(s.unavailable).toBeUndefined()
})
it('cutRelease 纯远端切分支,不建本地分支', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
expect(git(fx.origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(fx.origin, ['rev-parse', 'main']))
expect(() => git(fx.repo, ['rev-parse', '--verify', 'refs/heads/release/2026-08-31'])).toThrow()
await expect(cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })).rejects.toThrow(/已存在/)
})
it('unreleased 计数忽略已 cherry-pick 的提交;cherryPickToRelease 推远端并清理 worktree', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
const fix = commit(fx.peer, 'b.txt', 'b', 'fix b')
commit(fx.peer, 'c.txt', 'c', 'feat c')
git(fx.peer, ['push', 'origin', 'main'])
let s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' })
expect(s).toMatchObject({ current: 'release/2026-08-31', unreleased: 2 })
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
expect(list.map(c => c.subject)).toEqual(['feat c', 'fix b'])
await cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix] })
expect(git(fx.origin, ['log', '--format=%s', 'release/2026-08-31'])).toBe('fix b\ninit')
expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1)
s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' })
expect(s.unreleased).toBe(1)
})
it('cherry-pick 冲突 → 抛错、远端不变、worktree 已清理', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
commit(fx.peer, 'a.txt', 'a2', 'change a')
const conflict = commit(fx.peer, 'a.txt', 'a3', 'change a again')
git(fx.peer, ['push', 'origin', 'main'])
const before = git(fx.origin, ['rev-parse', 'release/2026-08-31'])
await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [conflict] })).rejects.toThrow(/冲突/)
expect(git(fx.origin, ['rev-parse', 'release/2026-08-31'])).toBe(before)
expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1)
})
it('listReleaseBranches 新→旧', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-01' })
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
git(fx.repo, ['fetch', 'origin'])
expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01'])
})
})
```
- [ ] **Step 2: 跑测试确认失败**:`pnpm vitest run test/git/release.test.ts` → 模块不存在。
- [ ] **Step 3: 实现 `src/git/release.ts`**
```ts
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
const DEFAULT_PREFIX = 'release/'
export class ReleaseError extends Error {
constructor(message: string, public readonly detail?: string) { super(message); this.name = 'ReleaseError' }
}
interface Run { ok: boolean, stdout: string, stderr: string }
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<Run> {
return new Promise((resolve) => {
execFile('git', ['-C', cwd, ...args], { timeout: timeoutMs, encoding: 'utf8' }, (err, stdout, stderr) => {
resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' })
})
})
}
async function mustGit(cwd: string, args: string[], failLabel: string): Promise<string> {
const r = await runGit(cwd, args)
if (!r.ok)
throw new ReleaseError(`${failLabel}:${tail(r.stderr)}`, r.stderr)
return r.stdout.trim()
}
const tail = (s: string, n = 400): string => s.trim().slice(-n)
export function normalizeReleasePrefix(raw: string): string {
const t = raw.trim()
if (!t) return DEFAULT_PREFIX
return t.endsWith('/') ? t : `${t}/`
}
export function todayReleaseBranch(prefix: string, now = new Date()): string {
const y = now.getFullYear(), m = String(now.getMonth() + 1).padStart(2, '0'), d = String(now.getDate()).padStart(2, '0')
return `${prefix}${y}-${m}-${d}`
}
export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined {
return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
}
async function remoteBranchNames(cwd: string, prefix: string): Promise<string[]> {
const out = await mustGit(cwd, ['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/origin/${prefix}`], '读取远端发布分支失败')
return out ? out.split('\n') : []
}
// … getReleaseStatus:fetch --prune 失败 → unavailable '拉取远端失败:…';origin/<devBranch> 不存在 → unavailable `远端没有 ${devBranch}`;
// current = pickCurrentRelease;unreleased = rev-list --count --right-only --cherry-pick --no-merges origin/<current>...origin/<devBranch>
// … listReleaseBranches:remoteBranchNames 过滤排序后 reverse
// … listUnreleasedCommits:log --right-only --cherry-pick --no-merges --format=%H%x00%s%x00%an%x00%as,按 \0 拆
// … cutRelease:fetch origin;show-ref --verify --quiet refs/remotes/origin/<branch> 成功 → throw new ReleaseError(`发布分支 ${branch} 已存在`);
// push origin origin/<devBranch>:refs/heads/<branch>;返回 rev-list --count origin/<prev>..origin/<devBranch>(prev = 切之前的 current,无则 0)
// … cherryPickToRelease:fetch origin;tmp = mkdtemp(os.tmpdir()/sw-release-);worktree add --detach tmp origin/<branch>;
// for sha of shas: cherry-pick -x sha,失败 → cherry-pick --abort(忽略结果)→ throw new ReleaseError(`${sha.slice(0,7)} ${subject} 冲突,请手动处理`);
// push origin HEAD:refs/heads/<branch>;finally: worktree remove --force tmp(忽略失败)+ rm(tmp, recursive, force) + worktree prune
```
把上面注释里的六个函数写全,不留注释占位;subject 通过 `git log -1 --format=%s <sha>` 取。
- [ ] **Step 4: 跑测试通过**:`pnpm vitest run test/git/release.test.ts`;`pnpm typecheck`、`pnpm lint` 0 错。
- [ ] **Step 5: Commit**:`✨ feat(vscode): 发布分支 git 逻辑(切分支 / 未发布计数 / cherry-pick)`
---
### Task 2: `src/jenkins/trigger.ts`
**Files:**
- Create: `vscode/src/jenkins/trigger.ts`
- Test: `vscode/src/jenkins/trigger.test.ts`
**Interfaces:**
- Produces:
```ts
export function buildJenkinsArgs(job: string, params: Record<string, string>): string[] // ['jks','build',job,'-p','K=V',…]
export async function triggerJenkinsBuild(o: { job: string, params: Record<string, string>, exec?: ExecFn }): Promise<void>
export type ExecFn = (bin: string, args: string[]) => Promise<{ code: number | null, stderr: string }>
```
- [ ] **Step 1: 测试**
```ts
import { describe, expect, it } from 'vitest'
import { buildJenkinsArgs, triggerJenkinsBuild } from './trigger'
describe('buildJenkinsArgs', () => {
it('每个参数一个 -p,不带 -s', () => {
expect(buildJenkinsArgs('luffy', { REF: 'release/2026-08-31', REMOTE_HOST: '正式服' }))
.toEqual(['jks', 'build', 'luffy', '-p', 'REF=release/2026-08-31', '-p', 'REMOTE_HOST=正式服'])
})
})
describe('triggerJenkinsBuild', () => {
it('opencli 缺失 → 明确报错', async () => {
const exec = async () => { const e = new Error('spawn opencli ENOENT') as NodeJS.ErrnoException; e.code = 'ENOENT'; throw e }
await expect(triggerJenkinsBuild({ job: 'j', params: {}, exec })).rejects.toThrow(/未找到 opencli/)
})
it('退出码非零 → 带 stderr', async () => {
const exec = async () => ({ code: 1, stderr: 'boom: not a valid parameter' })
await expect(triggerJenkinsBuild({ job: 'j', params: {}, exec })).rejects.toThrow(/not a valid parameter/)
})
it('成功', async () => {
const calls: string[][] = []
const exec = async (_b: string, a: string[]) => { calls.push(a); return { code: 0, stderr: '' } }
await triggerJenkinsBuild({ job: 'j', params: { REF: 'x' }, exec })
expect(calls).toEqual([['jks', 'build', 'j', '-p', 'REF=x']])
})
})
```
- [ ] **Step 2: 实现**:默认 `exec` 用 `execFile('opencli', args, { timeout: 60_000 })` 包成 Promise(`err.code === 'ENOENT'` 原样抛出,其它错误 resolve `{ code: err.code ?? 1, stderr }`);`triggerJenkinsBuild` 捕获 ENOENT → `throw new Error('未找到 opencli,请确认已安装并在 PATH 中')`;code ≠ 0 → `throw new Error(\`Jenkins 触发失败(退出码 ${code}):${stderr.trim().slice(-400)}\`)`。
- [ ] **Step 3: 测试 / typecheck / lint 通过。Commit**:`✨ feat(vscode): 通过 opencli 触发 Jenkins 构建`
---
### Task 3: 设置项与消息类型
**Files:**
- Modify: `vscode/src/settings/store.ts`(`autoBuildBranch` 全部相关行 → 三个新字段)
- Modify: `vscode/src/panel/handlers/settings.ts`(payload 类型、trim、持久化、回传,共 30/31、57/58、103/104、129-133、235/236 附近)
- Modify: `vscode/src/panel/messages.ts`(113-120 删 `branch-sync/status`,203-204 删 `branch-sync/check|run`;`settings/*` 里字段替换)
- Modify: `vscode/webview-ui/src/lib/messages.ts`(同上)
- Modify: `vscode/webview-ui/src/components/SettingsModal.tsx`(Props、state、保存 payload、471-489 的输入框换成三个)
- Modify: `vscode/test/gitea/stateJson.test.ts` 等凡引用 `autoBuildBranch` 的测试
**Interfaces:**
- Produces(`Settings` 新字段):`releaseBranchPrefix: string`(getSettings 读出时已经 `normalizeReleasePrefix`)、`jenkinsJob: string`(trim;`''` 合法)、`jenkinsProdHost: string`(trim 后空回退 `'正式服'`)。
- Produces(消息):
```ts
// 扩展 → webview
| { type: 'release/status', devBranch: string, current?: string, nextBranch: string, unreleased: number, unavailable?: string, running: boolean }
// nextBranch = todayReleaseBranch(prefix),菜单项「切新发布(…)」直接显示它,避免 webview 自己算日期
// webview → 扩展
| { type: 'release/check' } | { type: 'release/cut' } | { type: 'release/cherry-pick' } | { type: 'release/deploy' }
```
- [ ] **Step 1**:`store.ts` 默认值 `releaseBranchPrefix: 'release/'`, `jenkinsJob: ''`, `jenkinsProdHost: '正式服'`;归一化处照 `devBranch` 的写法。字段 JSDoc 用中文说明用途。
- [ ] **Step 2**:`handlers/settings.ts` 与两份 `messages.ts` 同步字段与消息类型(此时 `branch-sync` 的消费者还在,typecheck 会报错,允许——下一 Task 修)。
- [ ] **Step 3**:`SettingsModal.tsx`:三个 `Field`,label 分别「发布分支前缀」(hint:`线上跑的发布分支名前缀,默认 release/,按钮会自动取最新的一个`)、「Jenkins 任务名」(hint:`留空则不显示「部署到正式服」;触发时执行 opencli jks build <任务名> -p REF=… -p REMOTE_HOST=…`)、「正式服 REMOTE_HOST 值」(placeholder `正式服`)。
- [ ] **Step 4**:`pnpm vitest run src/settings` 相关通过。Commit:`♻️ refactor(vscode): 设置项 autoBuildBranch 换成发布分支前缀与 Jenkins 任务`
---
### Task 4: 扩展侧 handler 与旧同步代码删除
**Files:**
- Create: `vscode/src/panel/handlers/release.ts`
- Rename: `vscode/src/git/branchSync.ts` → `vscode/src/git/branches.ts`;`vscode/src/git/branchSync.test.ts` → `branches.test.ts`(删除 `checkBranchSync`、`runBranchSync`、`BranchSyncStatus`、`CheckBranchSyncOpts`、`RunBranchSyncOpts` 及其测试;保留 `runGit`、`gitFetch`、`resolveFeatureBranch`、`deleteLocalBranch`、`pruneRemoteTrackingRef`;文件头注释改成「通用分支助手」)
- Modify: `vscode/src/panel/handlers/toolbar.ts`(删 `handleBranchSyncCheck` / `handleBranchSyncRun` 及不再用的 import)、`issues.ts`、`worktree.ts`、`branchCleanup.ts` 的 import 路径
- Modify: `vscode/src/panel/KanbanPanel.ts`(509-515 分发、1015-1024 方法、`commitRunning` 旁加 `releaseRunning = false`)
**Interfaces:**
- Consumes:Task 1 / Task 2 全部导出;`toast` / `dismiss` 来自 `./handoffFlow`;`getSettings`。
- Produces:
```ts
export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise<void>
export async function handleReleaseCut(panel: KanbanWebviewPanel): Promise<void>
export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void>
export async function handleReleaseDeploy(panel: KanbanWebviewPanel): Promise<void>
```
- [ ] **Step 1: 改名与删除**:`git mv` 两个文件,删掉 sync 相关代码与测试,更新 4 处 import,删 toolbar / KanbanPanel 里旧 handler 与分发。`pnpm typecheck` 此时只剩 webview 侧报错。
- [ ] **Step 2: 写 `release.ts`**
```ts
export async function handleReleaseStatus(panel) {
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 }
if (!root) { panel.postMessage({ ...base, unreleased: 0, unavailable: '请先打开一个工作区文件夹' }); return }
const st = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
panel.postMessage({ ...base, current: st.current, unreleased: st.unreleased, unavailable: st.unavailable })
}
/** 三个写动作共用:互斥 + 状态刷新 + 统一错误 toast */
async function runReleaseAction(panel, 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) }
}
```
- `handleReleaseCut`:`branch = todayReleaseBranch(prefix)`;`count = await cutRelease(...)`;toast success `已切发布分支 ${branch}(${count} 个提交)`;`jenkinsJob` 非空 → `const pick = await window.showInformationMessage(\`现在部署 ${branch} 到${jenkinsProdHost}?\`, '部署')`,`pick === '部署'` 时 `await deploy(panel, branch)`。
- `handleReleaseCherryPick`:先 `getReleaseStatus` 拿 `current`;无 → `throw new Error('尚无发布分支,先切新发布')`;`listUnreleasedCommits` 空 → info toast『没有待摘的提交』并 return;`window.createQuickPick()`,`canSelectMany = true`,title `摘修复到 ${current}`,items `{ label: c.subject, description: \`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}\`, sha }`;accept 后按列表逆序(旧→新)取 sha;spinner toast `正在摘 N 个提交到 ${current}…`;`cherryPickToRelease`;success toast `已摘 ${n} 个提交到 ${current}`。用户按 Esc → 直接 return。
- `handleReleaseDeploy`:`listReleaseBranches`(先 `gitFetch`);空 → throw『尚无发布分支』;`window.showQuickPick(items, { title: '部署到正式服', placeHolder: '选择要部署的发布分支(默认当期,选旧的即回滚)' })`,items 首项 `{ label: current, description: '当期' }`,其余 `{ label, description: '回滚到此发布点' }`;选中后 `deploy(panel, label)`。
- `deploy(panel, branch)`:`triggerJenkinsBuild({ job, params: { REF: branch, REMOTE_HOST: jenkinsProdHost } })`;success toast `已触发 Jenkins ${job}:REF=${branch} → ${jenkinsProdHost}`。
- [ ] **Step 3: KanbanPanel 分发**:`release/check` → `handleReleaseStatus`;`release/cut|cherry-pick|deploy` → 对应 handler;原来调用 `handleBranchSyncCheck` 的时机(面板就绪 / 刷新)全部换成 `handleReleaseStatus`。
- [ ] **Step 4**:`pnpm test` 通过(webview typecheck 仍可能报错)。Commit:`✨ feat(vscode): 发布按钮扩展侧 handler,删除分支同步`
---
### Task 5: Webview 发布按钮
**Files:**
- Modify: `vscode/webview-ui/src/hooks/useIssues.ts`(删 315-318 五个 state、634-641 `runBranchSync`、827-845 分支、349-351 与 871 的 `branch-sync/check` → `release/check`;返回值同步)
- Modify: `vscode/webview-ui/src/components/PanelHeader.tsx`(Props 与按钮)
- Modify: `vscode/webview-ui/src/App.tsx`(三处透传)
**Interfaces:**
- Consumes:`release/status` 消息。
- Produces:`useIssues` 返回 `release: ReleaseStatus | undefined` 与 `runRelease(action: 'cut' | 'cherry-pick' | 'deploy'): void`;`PanelHeader` Props 新增 `release?: ReleaseStatus`、`jenkinsConfigured: boolean`(= settings.jenkinsJob 非空)、`onRelease(action)`,删除 `branchSync*` 与 `onSyncBranch`。
- [ ] **Step 1: useIssues**:`const [release, setRelease] = useState<ReleaseStatus>()`;`case 'release/status': setRelease(msg)`;`runRelease` = `postMessage({ type: \`release/${action}\` })`(running 由扩展侧状态决定,webview 不自置)。
- [ ] **Step 2: PanelHeader**:
```tsx
const [releaseMenuOpen, setReleaseMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!releaseMenuOpen) return
const onDown = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) setReleaseMenuOpen(false) }
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [releaseMenuOpen])
const releaseTitle = !release ? '正在检查发布状态…'
: release.unavailable ? release.unavailable
: !release.current ? '尚无发布点 · 点击切第一个发布分支'
: `正式服发布点 ${release.current} · ${release.unreleased > 0 ? `${release.devBranch} 领先 ${release.unreleased} 个提交` : '已全部发布'}`
const releaseDisabled = !release || !!release.unavailable || release.running
```
按钮:`<div ref={menuRef} className="relative">` 包住按钮与菜单;图标 `Rocket`(running 时 `Loader2 animate-spin`);`unreleased > 0` 时右上角 `<span className="absolute -right-1 -top-1 rounded-full bg-[var(--vscode-badge-background)] px-1 text-[9px] leading-3 text-[var(--vscode-badge-foreground)]">{unreleased > 99 ? '99+' : unreleased}</span>`;菜单 `absolute right-0 top-7 z-20 min-w-44 rounded border border-[var(--vscode-menu-border)] bg-[var(--vscode-menu-background)] py-1 text-[var(--vscode-menu-foreground)] shadow`,三项 `button.w-full.px-3.py-1.text-left.hover:bg-[var(--vscode-menu-selectionBackground)] disabled:opacity-50`:`切新发布(${release.nextBranch})`、`摘修复到 ${current}`(无 current 禁用)、`部署到正式服…`(`jenkinsConfigured` 为 false 不渲染)。点击项 → `onRelease(action)` + 关菜单。
- [ ] **Step 3: App.tsx** 三处:`release={release} jenkinsConfigured={settings.jenkinsJob.length > 0} onRelease={runRelease}`。
- [ ] **Step 4**:`pnpm typecheck`、`pnpm lint`、`pnpm test` 全绿。Commit:`✨ feat(vscode): 工具栏发布按钮与下拉菜单`
---
### Task 6: README、版本、打包
**Files:**
- Modify: `vscode/README.md`(第 86 行工具栏说明;128-129 设置表;工作流章节末尾新增「8. **发布**」一条:切发布 / 摘修复 / 部署与回滚,各一句)
- Modify: `vscode/package.json`(version patch +1)
- [ ] **Step 1**:README 三处改完。
- [ ] **Step 2**:`pnpm test && pnpm typecheck && pnpm lint` 全绿;在 `/Users/cruldra/Sources/luffy-agent-platform` 里手动核对:`git rev-list --count --right-only --cherry-pick --no-merges origin/release/2026-08-31...origin/main` 的数字,装好扩展后角标应一致。
- [ ] **Step 3**:Commit `📝 docs(vscode): README 发布按钮说明`;bump 版本 → `pnpm ext:package` → Commit `🔖 chore(vscode): bump x.y.z`;`code --install-extension <vsix> --force` 与 `code --profile augment --install-extension <vsix> --force`。