From 5284fc17307e7da008bb58292afbedeba1865ef4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 1 Sep 2026 19:03:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf(vscode):=20=E6=91=98=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=9C=AC=E5=9C=B0=E5=90=88=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=86=E7=BB=84=EF=BC=8C=E5=88=97=E8=A1=A8=E6=94=B9?= =?UTF-8?q?=E6=89=8B=E9=A3=8E=E7=90=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/package.json | 2 +- vscode/src/git/release.ts | 60 ++++++++- vscode/src/gitea/api.ts | 61 --------- vscode/src/gitea/commitPulls.test.ts | 35 ----- vscode/src/gitea/commitPulls.ts | 57 -------- vscode/src/panel/handlers/release.ts | 39 ++---- vscode/test/git/release.test.ts | 28 ++++ .../src/components/CherryPickModal.tsx | 122 ++++++++++++------ 8 files changed, 183 insertions(+), 221 deletions(-) delete mode 100644 vscode/src/gitea/commitPulls.test.ts delete mode 100644 vscode/src/gitea/commitPulls.ts diff --git a/vscode/package.json b/vscode/package.json index 1e62107..5b30fe6 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.109", + "version": "0.2.110", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index af9bca9..815afd4 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -82,7 +82,15 @@ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): st return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1) } -/** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自 Gitea,不是扫提交说明。 */ +/** Gitea 默认合并说明:Merge pull request '标题' (#260) from … */ +export function parseGiteaMergeSubject(subject: string): { number: number, title: string } | undefined { + const m = /^Merge pull request '(.+)' \(#(\d+)\)/.exec(subject) + if (!m) + return undefined + return { title: m[1], number: Number(m[2]) } +} + +/** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自合并记录,不是扫提交说明。 */ export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] { const prGroups = new Map() const prOrder: number[] = [] @@ -151,6 +159,56 @@ export async function listReleaseBranches(o: { cwd: string, prefix: string }): P return names.filter(b => b.startsWith(o.prefix)).sort().reverse() } +/** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */ +export async function indexUnreleasedByMergePr(o: { + cwd: string + current: string + devBranch: string + shas: readonly string[] +}): Promise> { + const needed = new Set(o.shas) + const out = new Map() + if (needed.size === 0) + return out + const raw = await mustGit( + o.cwd, + ['log', '--right-only', '--cherry-pick', '--merges', '--format=%H%x00%s', unreleasedRange(o.current, o.devBranch)], + '读取合并记录失败', + ) + if (!raw) + return out + for (const line of raw.split('\n')) { + const [sha, subject] = line.split('\0') + if (!sha || !subject) + continue + const parsed = parseGiteaMergeSubject(subject) + if (!parsed) + continue + const members = await mustGit( + o.cwd, + ['rev-list', '--no-merges', `${sha}^1..${sha}^2`], + `读取 PR #${parsed.number} 提交失败`, + ) + for (const member of members ? members.split('\n') : []) { + if (needed.has(member)) + out.set(member, parsed) + } + if (out.size === needed.size) + return out + } + return out +} + +export function attachPulls( + commits: T[], + index: Map, +): Array { + return commits.map((c) => { + const hit = index.get(c.sha) + return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c + }) +} + export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise { const out = await mustGit( o.cwd, diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index a0629ae..42e6486 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -362,67 +362,6 @@ export async function getPullRequest(opts: { } } -export async function listClosedPullsPage(opts: { - host: string - token: string - owner: string - repo: string - page: number -}): Promise> { - const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls`) - url.searchParams.set('state', 'closed') - url.searchParams.set('sort', 'recentupdate') - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(opts.page)) - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as Array<{ - number?: unknown - title?: unknown - merged?: unknown - merge_commit_sha?: unknown - }> - if (!Array.isArray(batch)) - return [] - return batch.map(p => ({ - number: typeof p.number === 'number' ? p.number : Number(p.number), - title: typeof p.title === 'string' ? p.title : '', - merged: p.merged === true, - mergeCommitSha: typeof p.merge_commit_sha === 'string' && p.merge_commit_sha.length > 0 - ? p.merge_commit_sha - : null, - })) -} - -export async function listPullRequestCommitShas(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const out: string[] = [] - let page = 1 - while (true) { - const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`) - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(page)) - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as Array<{ sha?: unknown }> - if (!Array.isArray(batch) || batch.length === 0) - break - for (const c of batch) { - if (typeof c.sha === 'string' && c.sha.length > 0) - out.push(c.sha) - } - if (batch.length < PAGE_SIZE) - break - page += 1 - } - return out -} - export async function getIssue(opts: { host: string token: string diff --git a/vscode/src/gitea/commitPulls.test.ts b/vscode/src/gitea/commitPulls.test.ts deleted file mode 100644 index ba250e5..0000000 --- a/vscode/src/gitea/commitPulls.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { indexCommitsByPull } from './commitPulls' - -describe('indexCommitsByPull', () => { - it('用已合并 PR 的提交列表把 sha 对上 PR,不看 commit message', async () => { - const map = await indexCommitsByPull( - ['3e519ea', '5572c4f', 'orphan'], - async (page) => { - if (page !== 1) - return [] - return [ - { number: 260, title: '连续包月会员订阅体系', merged: true, mergeCommitSha: 'merge260' }, - { number: 99, title: '还没合', merged: false, mergeCommitSha: null }, - ] - }, - async (index) => { - if (index === 260) - return ['3e519ea', '5572c4f', 'other'] - throw new Error(`should not fetch commits for PR ${index}`) - }, - ) - expect(map.get('3e519ea')).toEqual({ number: 260, title: '连续包月会员订阅体系' }) - expect(map.get('5572c4f')).toEqual({ number: 260, title: '连续包月会员订阅体系' }) - expect(map.has('orphan')).toBe(false) - }) - - it('squash 合入时用 mergeCommitSha 对上', async () => { - const map = await indexCommitsByPull( - ['squashsha'], - async () => [{ number: 8, title: 'squash pr', merged: true, mergeCommitSha: 'squashsha' }], - async () => ['original-on-branch'], - ) - expect(map.get('squashsha')).toEqual({ number: 8, title: 'squash pr' }) - }) -}) diff --git a/vscode/src/gitea/commitPulls.ts b/vscode/src/gitea/commitPulls.ts deleted file mode 100644 index 63755f4..0000000 --- a/vscode/src/gitea/commitPulls.ts +++ /dev/null @@ -1,57 +0,0 @@ -export interface PullRef { - number: number - title: string - merged: boolean - mergeCommitSha: string | null -} - -export type ListPullsPage = (page: number) => Promise -export type ListPullCommits = (index: number) => Promise - -/** - * 把未发布提交的 sha 对上已合并 PR。 - * 只认 Gitea 的 PR 提交列表 / squash 的 mergeCommitSha,不扫 commit message。 - */ -export async function indexCommitsByPull( - shas: readonly string[], - listPulls: ListPullsPage, - listCommits: ListPullCommits, -): Promise> { - const needed = new Set(shas) - const out = new Map() - if (needed.size === 0) - return out - - for (let page = 1; page <= 20; page++) { - const batch = await listPulls(page) - if (batch.length === 0) - break - for (const pr of batch) { - if (!pr.merged) - continue - const ref = { number: pr.number, title: pr.title } - if (pr.mergeCommitSha && needed.has(pr.mergeCommitSha)) - out.set(pr.mergeCommitSha, ref) - const commits = await listCommits(pr.number) - for (const sha of commits) { - if (needed.has(sha)) - out.set(sha, ref) - } - if (out.size === needed.size) - return out - } - if (batch.length < 50) - break - } - return out -} - -export function attachPulls( - commits: T[], - index: Map, -): Array { - return commits.map((c) => { - const hit = index.get(c.sha) - return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c - }) -} diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index 51ed651..cc93845 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -7,16 +7,14 @@ import type { KanbanWebviewPanel } from '../KanbanPanel' import { window, workspace } from 'vscode' -import { getToken } from '../../auth/secrets' -import { listClosedPullsPage, listPullRequestCommitShas } from '../../gitea/api' -import { attachPulls, indexCommitsByPull } from '../../gitea/commitPulls' import { gitFetch } from '../../git/branches' -import { detectRepo } from '../../git/remote' import { + attachPulls, cherryPickToRelease, cutRelease, getReleaseStatus, groupCommitsByPr, + indexUnreleasedByMergePr, listReleaseBranches, listUnreleasedCommits, todayReleaseBranch, @@ -105,28 +103,17 @@ export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promis toast(panel, 'info', '没有待摘的提交') return } - let annotated = commits - const spinner = toast(panel, 'info', '正在按 PR 分组…', { spinner: true, dismissOnTimer: 0 }) - try { - const remote = await detectRepo(root) - const token = remote ? await getToken(panel.context, remote.host) : undefined - if (remote && token) { - const gitea = { host: remote.host, token, owner: remote.owner, repo: remote.repo } - const index = await indexCommitsByPull( - commits.map(c => c.sha), - page => listClosedPullsPage({ ...gitea, page }), - pr => listPullRequestCommitShas({ ...gitea, index: pr }), - ) - annotated = attachPulls(commits, index) - } - } - catch { - toast(panel, 'error', '按 PR 分组失败,先按未关联列出', { dismissOnTimer: 5000 }) - } - finally { - dismiss(panel, spinner) - } - panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(annotated) }) + const index = await indexUnreleasedByMergePr({ + cwd: root, + current, + devBranch: s.devBranch, + shas: commits.map(c => c.sha), + }) + panel.postMessage({ + type: 'release/pick-show', + branch: current, + groups: groupCommitsByPr(attachPulls(commits, index)), + }) } catch (err) { toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index f4389ea..90f2384 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -13,6 +13,8 @@ import { pickCurrentRelease, todayReleaseBranch, groupCommitsByPr, + parseGiteaMergeSubject, + indexUnreleasedByMergePr, } 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' } @@ -57,6 +59,13 @@ 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('parseGiteaMergeSubject 认 Gitea 合并说明', () => { + expect(parseGiteaMergeSubject("Merge pull request '连续包月会员订阅体系' (#260) from feature/monthly-membership into main")).toEqual({ + number: 260, + title: '连续包月会员订阅体系', + }) + expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined() + }) it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => { const groups = groupCommitsByPr([ { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, @@ -133,4 +142,23 @@ describe('集成', () => { git(fx.repo, ['fetch', 'origin']) expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01']) }) + it('indexUnreleasedByMergePr 用本地合并记录把分支提交对上 PR', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + git(fx.peer, ['checkout', '-b', 'feature']) + const a = commit(fx.peer, 'b.txt', 'b', 'fix(admin): 会员档位隐藏') + const b = commit(fx.peer, 'c.txt', 'c', 'fix(membership): 年付月度发卡') + git(fx.peer, ['checkout', 'main']) + git(fx.peer, ['merge', '--no-ff', '-m', "Merge pull request '连续包月会员订阅体系' (#260) from feature into main", 'feature']) + git(fx.peer, ['push', 'origin', 'main']) + git(fx.repo, ['fetch', 'origin']) + const map = await indexUnreleasedByMergePr({ + cwd: fx.repo, + current: 'release/2026-08-31', + devBranch: 'main', + shas: [a, b], + }) + expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) + expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) + }) }) diff --git a/vscode/webview-ui/src/components/CherryPickModal.tsx b/vscode/webview-ui/src/components/CherryPickModal.tsx index a340569..23cf607 100644 --- a/vscode/webview-ui/src/components/CherryPickModal.tsx +++ b/vscode/webview-ui/src/components/CherryPickModal.tsx @@ -1,5 +1,5 @@ import type { ReleaseCommitGroup } from '../lib/messages' -import { X } from 'lucide-react' +import { ChevronRight, X } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' interface Props { @@ -10,6 +10,10 @@ interface Props { onConfirm: (shas: string[]) => void } +function groupKey(g: ReleaseCommitGroup): string { + return g.pr != null ? `pr-${g.pr}` : 'ungrouped' +} + function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] { const q = query.trim().toLowerCase() if (!q) @@ -33,12 +37,14 @@ function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommi export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) { const [query, setQuery] = useState('') const [selected, setSelected] = useState>(() => new Set()) + const [expanded, setExpanded] = useState>(() => new Set()) useEffect(() => { if (!open) return setQuery('') setSelected(new Set()) + setExpanded(new Set(groups.filter(g => g.pr == null).map(groupKey))) }, [open, groups]) useEffect(() => { @@ -68,7 +74,7 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P }) } - function toggleGroup(g: ReleaseCommitGroup): void { + function toggleGroupSelected(g: ReleaseCommitGroup): void { const shas = g.commits.map(c => c.sha) const allOn = shas.every(s => selected.has(s)) setSelected((prev) => { @@ -83,6 +89,18 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P }) } + function toggleExpanded(g: ReleaseCommitGroup): void { + const key = groupKey(g) + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(key)) + next.delete(key) + else + next.add(key) + return next + }) + } + const count = selected.size return ( @@ -112,45 +130,69 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
{visible.length === 0 ?
没有匹配的提交
- : visible.map(g => ( -
- -
    - {g.commits.map(c => ( -
  • - -
  • - ))} -
-
- ))} +
+ + { + if (el) + el.indeterminate = someOn && !allOn + }} + onChange={() => toggleGroupSelected(g)} + aria-label="全选本组" + className="shrink-0" + /> +
+ {openGroup && ( +
    + {g.commits.map(c => ( +
  • + +
  • + ))} +
+ )} + + ) + })}