From 3e3750c4942250cce4f0c1f19395000ee0ce1e4e Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 1 Sep 2026 17:27:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=91=98=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=20PR=20=E5=88=86=E7=BB=84=E6=A1=86=E9=80=89?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E6=90=9C=E4=BD=9C=E8=80=85=E5=92=8C=E6=A0=87?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/git/release.ts | 66 ++++++- vscode/src/panel/KanbanPanel.ts | 4 + vscode/src/panel/handlers/release.ts | 61 +++--- vscode/src/panel/messages.ts | 3 + vscode/test/git/release.test.ts | 34 ++++ vscode/webview-ui/src/App.tsx | 15 +- .../src/components/CherryPickModal.tsx | 174 ++++++++++++++++++ vscode/webview-ui/src/hooks/useIssues.ts | 21 ++- vscode/webview-ui/src/lib/messages.ts | 17 ++ 9 files changed, 353 insertions(+), 42 deletions(-) create mode 100644 vscode/webview-ui/src/components/CherryPickModal.tsx diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index b7b9ff9..e30305c 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -24,6 +24,14 @@ export interface UnreleasedCommit { subject: string author: string date: string + body: string +} + +export interface ReleaseCommitGroup { + pr?: number + title: string + authors: string[] + commits: UnreleasedCommit[] } export class ReleaseError extends Error { @@ -71,6 +79,53 @@ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): st return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1) } +export function parsePrNumber(text: string): number | undefined { + const close = /\b(?:closes|fixes|resolves)\s+#(\d+)/i.exec(text) + if (close) + return Number(close[1]) + const paren = /\(#(\d+)\)/.exec(text) + if (paren) + return Number(paren[1]) + return undefined +} + +function stripPrFromSubject(subject: string): string { + return subject.replace(/\s*\(#\d+\)\s*$/, '').trim() +} + +/** 同 PR 收一组(新→旧首次出现顺序),认不出 PR 的进「未关联 PR」。 */ +export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] { + const prGroups = new Map() + const prOrder: number[] = [] + const ungrouped: UnreleasedCommit[] = [] + for (const c of commits) { + const pr = parsePrNumber(`${c.subject}\n${c.body}`) + if (pr === undefined) { + ungrouped.push(c) + continue + } + let g = prGroups.get(pr) + if (!g) { + g = { pr, title: stripPrFromSubject(c.subject), authors: [], commits: [] } + prGroups.set(pr, g) + prOrder.push(pr) + } + g.commits.push(c) + if (!g.authors.includes(c.author)) + g.authors.push(c.author) + } + const out = prOrder.map(n => prGroups.get(n)!) + if (ungrouped.length > 0) { + const authors: string[] = [] + for (const c of ungrouped) { + if (!authors.includes(c.author)) + authors.push(c.author) + } + out.push({ title: '未关联 PR', authors, commits: ungrouped }) + } + return out +} + async function remoteBranchNames(cwd: string, prefix: string): Promise { const out = await mustGit(cwd, ['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/origin/${prefix}`], '读取远端发布分支失败') return out ? out.split('\n') : [] @@ -110,14 +165,17 @@ export async function listReleaseBranches(o: { cwd: string, prefix: string }): P export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise { const out = await mustGit( o.cwd, - ['log', '--right-only', '--cherry-pick', '--no-merges', '--format=%H%x00%s%x00%an%x00%as', unreleasedRange(o.current, o.devBranch)], + ['log', '--right-only', '--cherry-pick', '--no-merges', '--format=%H%x00%s%x00%an%x00%as%x00%b%x1e', unreleasedRange(o.current, o.devBranch)], '读取未发布提交失败', ) if (!out) return [] - return out.split('\n').map((line) => { - const [sha, subject, author, date] = line.split('\0') - return { sha, subject, author, date } + return out.split('\x1e').flatMap((chunk) => { + const trimmed = chunk.replace(/^\n+/, '').trimEnd() + if (!trimmed) + return [] + const [sha, subject, author, date, body = ''] = trimmed.split('\0') + return [{ sha, subject, author, date, body: body.trim() }] }) } diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 2c14260..3b7fca0 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -538,6 +538,10 @@ export class KanbanWebviewPanel { void release.handleReleaseCherryPick(this) return } + if (msg.type === 'release/cherry-pick-confirm') { + void release.handleReleaseCherryPickConfirm(this, msg.shas) + return + } if (msg.type === 'release/deploy') { void release.handleReleaseDeploy(this) return diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index e4c95fd..e799d41 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -12,6 +12,7 @@ import { cherryPickToRelease, cutRelease, getReleaseStatus, + groupCommitsByPr, listReleaseBranches, listUnreleasedCommits, todayReleaseBranch, @@ -85,10 +86,8 @@ export function handleReleaseCut(panel: KanbanWebviewPanel): Promise { }) } -interface CommitPickItem { label: string, description: string, sha: string } - -export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise { - return runReleaseAction(panel, async () => { +export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise { + try { const root = requireRoot() const s = getSettings(panel.context) const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) @@ -102,44 +101,36 @@ export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise ({ - label: c.subject, - description: `${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`, - sha: c.sha, - }))) - if (!picked || picked.length === 0) + panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(commits) }) + } + catch (err) { + toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) + } +} + +export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: string[]): Promise { + return runReleaseAction(panel, async () => { + if (shas.length === 0) return - // 列表是 git log 顺序(新→旧),cherry-pick 要按时间顺序(旧→新) + 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 order = new Map(commits.map((c, i) => [c.sha, i])) - const shas = [...picked].sort((a, b) => (order.get(b.sha) ?? 0) - (order.get(a.sha) ?? 0)).map(p => p.sha) - const spinner = toast(panel, 'info', `正在摘 ${shas.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 }) + const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0)) + const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 }) try { - await cherryPickToRelease({ cwd: root, branch: current, shas }) + await cherryPickToRelease({ cwd: root, branch: current, shas: ordered }) } finally { dismiss(panel, spinner) } - toast(panel, 'success', `已摘 ${shas.length} 个提交到 ${current}`) - }) -} - -/** 多选 QuickPick;用户按 Esc 返回 undefined */ -function pickCommits(current: string, items: CommitPickItem[]): Promise { - return new Promise((resolve) => { - const qp = window.createQuickPick() - qp.canSelectMany = true - qp.title = `摘修复到 ${current}` - qp.placeholder = '勾选要摘到发布分支的提交' - qp.items = items - qp.onDidAccept(() => { - resolve([...qp.selectedItems]) - qp.hide() - }) - qp.onDidHide(() => { - resolve(undefined) - qp.dispose() - }) - qp.show() + toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`) }) } diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index c99f48f..1f9cdf5 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -1,3 +1,4 @@ +import type { ReleaseCommitGroup } from '../git/release' import type { Issue, IssueColumn } from '../gitea/types' import type { LogEntry } from '../logging/logger' import type { ProfilesData } from '../profiles/store' @@ -122,6 +123,7 @@ export type ExtensionToWebview running: boolean jenkinsConfigured: boolean } + | { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] } | { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number } | { type: 'issue/remove', issueNumber: number } | { type: 'profiles/show', data: ProfilesData } @@ -210,6 +212,7 @@ export type WebviewToExtension | { type: 'release/check' } | { type: 'release/cut' } | { type: 'release/cherry-pick' } + | { type: 'release/cherry-pick-confirm', shas: string[] } | { type: 'release/deploy' } | { type: 'env-lock/check' } | { type: 'env-lock/toggle' } diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index 9a63350..56998ec 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -12,6 +12,8 @@ import { normalizeReleasePrefix, pickCurrentRelease, todayReleaseBranch, + parsePrNumber, + groupCommitsByPr, } 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' } @@ -56,6 +58,38 @@ describe('纯函数', () => { expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31') expect(pickCurrentRelease('release/', ['main'])).toBeUndefined() }) + it('parsePrNumber 认 Closes/Fixes/Resolves 和 (#N)', () => { + expect(parsePrNumber('feat: x (#390)')).toBe(390) + expect(parsePrNumber('fix: y\n\nCloses #12')).toBe(12) + expect(parsePrNumber('fix: y\n\nFixes #3')).toBe(3) + expect(parsePrNumber('fix: y\n\nResolves #9')).toBe(9) + expect(parsePrNumber('feat: no pr')).toBeUndefined() + }) + it('groupCommitsByPr 同 PR 收一组,无 PR 进未关联,保持新→旧', () => { + const groups = groupCommitsByPr([ + { sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' }, + { sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' }, + { sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' }, + ]) + expect(groups).toEqual([ + { + pr: 2, + title: 'feat: later', + authors: ['me', 'you'], + commits: [ + { sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' }, + { sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' }, + ], + }, + { + title: '未关联 PR', + authors: ['you'], + commits: [ + { sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' }, + ], + }, + ]) + }) }) describe('集成', () => { diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index db1e05e..d18ad65 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -2,6 +2,7 @@ import type { PastedImage } from './components/NewIssueModal' import type { Issue } from './types' import { useEffect, useMemo, useRef, useState } from 'react' import { BottomTabs } from './components/BottomTabs' +import { CherryPickModal } from './components/CherryPickModal' import { HandoffModal } from './components/HandoffModal' import { KanbanBoard } from './components/KanbanBoard' import { LogModal } from './components/LogModal' @@ -69,6 +70,9 @@ export function App() { hasChanges, release, runRelease, + cherryPick, + dismissCherryPick, + confirmCherryPick, envLocked, envFileCount, envLockRunning, @@ -183,6 +187,8 @@ export function App() { return if (showLogs) return + if (cherryPick !== null) + return if (settingsOpen) return if (handoffIssue !== null || handoffSession !== null) @@ -285,7 +291,7 @@ export function App() { window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [state.status, showNewIssueModal, showLogs, settingsOpen, ordered, selectedId, selectedIssue, resumeSession]) + }, [state.status, showNewIssueModal, showLogs, settingsOpen, cherryPick, ordered, selectedId, selectedIssue, resumeSession]) // When the user is being forced into first-time setup, the kanban behind // the modal has nothing meaningful to show — replace the spinner with a @@ -491,6 +497,13 @@ export function App() { setHandoffSession(null) }} /> + void + onConfirm: (shas: string[]) => void +} + +function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] { + const q = query.trim().toLowerCase() + if (!q) + return groups + const out: ReleaseCommitGroup[] = [] + for (const g of groups) { + const head = `#${g.pr ?? ''} ${g.title} ${g.authors.join(' ')}`.toLowerCase() + if (head.includes(q)) { + out.push(g) + continue + } + const commits = g.commits.filter(c => + `${c.subject} ${c.author} ${c.sha} ${c.date}`.toLowerCase().includes(q), + ) + if (commits.length > 0) + out.push({ ...g, commits }) + } + return out +} + +export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) { + const [query, setQuery] = useState('') + const [selected, setSelected] = useState>(() => new Set()) + + useEffect(() => { + if (!open) + return + setQuery('') + setSelected(new Set()) + }, [open, groups]) + + useEffect(() => { + if (!open) + return + function onKey(e: KeyboardEvent): void { + if (e.key === 'Escape') + onCancel() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open, onCancel]) + + const visible = useMemo(() => filterGroups(groups, query), [groups, query]) + + if (!open) + return null + + function toggleSha(sha: string): void { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(sha)) + next.delete(sha) + else + next.add(sha) + return next + }) + } + + function toggleGroup(g: ReleaseCommitGroup): void { + const shas = g.commits.map(c => c.sha) + const allOn = shas.every(s => selected.has(s)) + setSelected((prev) => { + const next = new Set(prev) + for (const s of shas) { + if (allOn) + next.delete(s) + else + next.add(s) + } + return next + }) + } + + const count = selected.size + + return ( +
+
+
+

+ {`摘修复到 ${branch}`} +

+ +
+
+ setQuery(e.target.value)} + placeholder="搜索作者、PR 号或标题" + className="w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1.5 text-xs outline-none" + /> +
+
+ {visible.length === 0 + ?
没有匹配的提交
+ : visible.map(g => ( +
+ +
    + {g.commits.map(c => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+ + {`已选 ${count}`} + + + +
+
+
+ ) +} diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 41ffe45..dd068e6 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -17,7 +17,7 @@ */ import type { ToastItem } from '../components/ToastStack' -import type { ExtensionToWebview, LogEntry } from '../lib/messages' +import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages' import type { Issue, IssueColumn } from '../types' import { useCallback, useEffect, useRef, useState } from 'react' import { compareIssuesInColumn } from '../lib/issueSort' @@ -246,6 +246,10 @@ export interface UseIssuesResult { release: ReleaseStatus | undefined /** 发布按钮三个动作;running 由扩展侧状态决定,webview 不自置。 */ runRelease: (action: ReleaseAction) => void + /** 摘修复选单:扩展推来分组后的未发布提交。 */ + cherryPick: { branch: string, groups: ReleaseCommitGroup[] } | null + dismissCherryPick: () => void + confirmCherryPick: (shas: string[]) => void /** * Whether the workspace's `.env*` files are currently chmod-locked * according to `workspaceState`. Persisted per-workspace. @@ -336,6 +340,7 @@ export function useIssues(): UseIssuesResult { const [commitRunning, setCommitRunning] = useState(false) const [hasChanges, setHasChanges] = useState(false) const [release, setRelease] = useState() + const [cherryPick, setCherryPick] = useState<{ branch: string, groups: ReleaseCommitGroup[] } | null>(null) const [envLocked, setEnvLocked] = useState(false) const [envFileCount, setEnvFileCount] = useState(0) const [envLockRunning, setEnvLockRunning] = useState(false) @@ -656,6 +661,15 @@ export function useIssues(): UseIssuesResult { postMessage({ type: `release/${action}` }) }, []) + const dismissCherryPick = useCallback((): void => { + setCherryPick(null) + }, []) + + const confirmCherryPick = useCallback((shas: string[]): void => { + setCherryPick(null) + postMessage({ type: 'release/cherry-pick-confirm', shas }) + }, []) + const toggleEnvLock = useCallback((): void => { // Double-click guard — the next `env-lock/status` clears the running flag. if (envLockRunning) @@ -845,6 +859,9 @@ export function useIssues(): UseIssuesResult { case 'release/status': setRelease(msg) break + case 'release/pick-show': + setCherryPick({ branch: msg.branch, groups: msg.groups }) + break case 'env-lock/status': { setEnvLocked(msg.locked) setEnvFileCount(msg.fileCount) @@ -873,5 +890,5 @@ export function useIssues(): UseIssuesResult { return cleanup }, [clearPrDiffSummaryRunning, markHandoffRunning]) - return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } + return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 6a2c14b..425105b 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -23,6 +23,21 @@ export interface PrFile { previousFilename?: string } +export interface ReleaseCommit { + sha: string + subject: string + author: string + date: string + body: string +} + +export interface ReleaseCommitGroup { + pr?: number + title: string + authors: string[] + commits: ReleaseCommit[] +} + export type ToastLevel = 'info' | 'success' | 'error' export interface SessionHandoffPendingItem { @@ -137,6 +152,7 @@ export type ExtensionToWebview running: boolean jenkinsConfigured: boolean } + | { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] } | { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number } | { type: 'issue/remove', issueNumber: number } | { type: 'profiles/show', data: ProfilesData } @@ -225,6 +241,7 @@ export type WebviewToExtension | { type: 'release/check' } | { type: 'release/cut' } | { type: 'release/cherry-pick' } + | { type: 'release/cherry-pick-confirm', shas: string[] } | { type: 'release/deploy' } | { type: 'env-lock/check' } | { type: 'env-lock/toggle' }