diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index e30305c..af9bca9 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -25,6 +25,9 @@ export interface UnreleasedCommit { author: string date: string body: string + /** Gitea 查到的所属 PR;没有就不分组。 */ + pr?: number + prTitle?: string } export interface ReleaseCommitGroup { @@ -79,34 +82,20 @@ 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」。 */ +/** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自 Gitea,不是扫提交说明。 */ 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}`) + const pr = c.pr if (pr === undefined) { ungrouped.push(c) continue } let g = prGroups.get(pr) if (!g) { - g = { pr, title: stripPrFromSubject(c.subject), authors: [], commits: [] } + g = { pr, title: c.prTitle?.trim() || c.subject, authors: [], commits: [] } prGroups.set(pr, g) prOrder.push(pr) } diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 42e6486..a0629ae 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -362,6 +362,67 @@ 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 new file mode 100644 index 0000000..ba250e5 --- /dev/null +++ b/vscode/src/gitea/commitPulls.test.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..63755f4 --- /dev/null +++ b/vscode/src/gitea/commitPulls.ts @@ -0,0 +1,57 @@ +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 e799d41..51ed651 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -7,7 +7,11 @@ 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 { cherryPickToRelease, cutRelease, @@ -101,7 +105,28 @@ export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promis toast(panel, 'info', '没有待摘的提交') return } - panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(commits) }) + 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) }) } 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 56998ec..f4389ea 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -12,7 +12,6 @@ import { normalizeReleasePrefix, pickCurrentRelease, todayReleaseBranch, - parsePrNumber, groupCommitsByPr, } from '../../src/git/release' @@ -58,27 +57,20 @@ 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 进未关联,保持新→旧', () => { + it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => { 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: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, + { sha: '5572c4f', subject: 'fix(membership): 年付月度发卡', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, { sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' }, ]) expect(groups).toEqual([ { - pr: 2, - title: 'feat: later', - authors: ['me', 'you'], + pr: 260, + title: '连续包月会员订阅体系', + authors: ['chehongweo'], 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' }, + { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, + { sha: '5572c4f', subject: 'fix(membership): 年付月度发卡', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, ], }, {