📝 docs(vscode): 发布按钮实施计划

Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
This commit is contained in:
cruldra committed 2026-08-31 23:32:00 +08:00
1 parent 73c6fbcd0a
commit ac8d4bc7a7
1 file changed
+394
@@ -0,0 +1,394 @@
# 工具栏「发布」按钮 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`。