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

26 KiB
Raw Blame History

工具栏「发布」按钮 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:

    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() 写法:

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

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:

    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: 测试

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(消息):

    // 扩展 → 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:

    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

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:

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。