⚡ perf(vscode): 摘修复用本地合并记录分组,列表改手风琴

This commit is contained in:
cruldra committed 2026-09-01 19:03:56 +08:00
1 parent cfabee08b7
commit 5284fc1730
8 files changed
+152 -190

No files matched your search

+1 -1
View File
@@ -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",
+59 -1
View File
@@ -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<number, ReleaseCommitGroup>()
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<Map<string, { number: number, title: string }>> {
const needed = new Set(o.shas)
const out = new Map<string, { number: number, title: string }>()
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<T extends { sha: string }>(
commits: T[],
index: Map<string, { number: number, title: string }>,
): Array<T & { pr?: number, prTitle?: string }> {
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<UnreleasedCommit[]> {
const out = await mustGit(
o.cwd,
-61
View File
@@ -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<Array<{ number: number, title: string, merged: boolean, mergeCommitSha: string | null }>> {
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<string[]> {
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
-35
View File
@@ -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' })
})
})
-57
View File
@@ -1,57 +0,0 @@
export interface PullRef {
number: number
title: string
merged: boolean
mergeCommitSha: string | null
}
export type ListPullsPage = (page: number) => Promise<PullRef[]>
export type ListPullCommits = (index: number) => Promise<string[]>
/**
* 把未发布提交的 sha 对上已合并 PR。
* 只认 Gitea 的 PR 提交列表 / squash 的 mergeCommitSha,不扫 commit message。
*/
export async function indexCommitsByPull(
shas: readonly string[],
listPulls: ListPullsPage,
listCommits: ListPullCommits,
): Promise<Map<string, { number: number, title: string }>> {
const needed = new Set(shas)
const out = new Map<string, { number: number, title: string }>()
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<T extends { sha: string }>(
commits: T[],
index: Map<string, { number: number, title: string }>,
): Array<T & { pr?: number, prTitle?: string }> {
return commits.map((c) => {
const hit = index.get(c.sha)
return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c
})
}
+13 -26
View File
@@ -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 })
+28
View File
@@ -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: '连续包月会员订阅体系' })
})
})
@@ -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<Set<string>>(() => new Set())
const [expanded, setExpanded] = useState<Set<string>>(() => 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,24 +130,46 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-3">
{visible.length === 0
? <div className="py-8 text-center text-xs opacity-70">没有匹配的提交</div>
: visible.map(g => (
: visible.map((g) => {
const key = groupKey(g)
const openGroup = query.trim() !== '' || expanded.has(key)
const shas = g.commits.map(c => c.sha)
const allOn = shas.length > 0 && shas.every(s => selected.has(s))
const someOn = shas.some(s => selected.has(s))
return (
<section
key={g.pr ?? `ungrouped-${g.commits[0]?.sha}`}
key={key}
className="rounded border border-[var(--vscode-panel-border)]"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleGroup(g)}
className="flex w-full items-center gap-2 border-b border-[var(--vscode-panel-border)] px-3 py-2 text-left hover:bg-white/5"
aria-expanded={openGroup}
onClick={() => toggleExpanded(g)}
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:opacity-90"
>
<span className="flex-1 truncate font-medium">
<ChevronRight className={`size-3.5 shrink-0 opacity-70 transition-transform ${openGroup ? 'rotate-90' : ''}`} />
<span className="min-w-0 flex-1 truncate font-medium">
{g.pr != null ? `#${g.pr} ${g.title}` : g.title}
</span>
<span className="shrink-0 text-[11px] opacity-60">
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
</span>
</button>
<ul className="py-1">
<input
type="checkbox"
checked={allOn}
ref={(el) => {
if (el)
el.indeterminate = someOn && !allOn
}}
onChange={() => toggleGroupSelected(g)}
aria-label="全选本组"
className="shrink-0"
/>
</div>
{openGroup && (
<ul className="border-t border-[var(--vscode-panel-border)] py-1">
{g.commits.map(c => (
<li key={c.sha}>
<label className="flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5">
@@ -149,8 +189,10 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
</li>
))}
</ul>
)}
</section>
))}
)
})}
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-3">
<span className="mr-auto text-xs opacity-70">