🐛 fix(vscode): 摘修复按 Gitea PR 提交列表分组

This commit is contained in:
cruldra committed 2026-09-01 18:53:41 +08:00
1 parent c0f8da5514
commit 4d3b2a0d67
6 files changed
+193 -34

No files matched your search

+6 -17
View File
@@ -25,6 +25,9 @@ export interface UnreleasedCommit {
author: string author: string
date: string date: string
body: string body: string
/** Gitea 查到的所属 PR;没有就不分组。 */
pr?: number
prTitle?: string
} }
export interface ReleaseCommitGroup { 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) return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
} }
export function parsePrNumber(text: string): number | undefined { /** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自 Gitea,不是扫提交说明。 */
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[] { export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] {
const prGroups = new Map<number, ReleaseCommitGroup>() const prGroups = new Map<number, ReleaseCommitGroup>()
const prOrder: number[] = [] const prOrder: number[] = []
const ungrouped: UnreleasedCommit[] = [] const ungrouped: UnreleasedCommit[] = []
for (const c of commits) { for (const c of commits) {
const pr = parsePrNumber(`${c.subject}\n${c.body}`) const pr = c.pr
if (pr === undefined) { if (pr === undefined) {
ungrouped.push(c) ungrouped.push(c)
continue continue
} }
let g = prGroups.get(pr) let g = prGroups.get(pr)
if (!g) { if (!g) {
g = { pr, title: stripPrFromSubject(c.subject), authors: [], commits: [] } g = { pr, title: c.prTitle?.trim() || c.subject, authors: [], commits: [] }
prGroups.set(pr, g) prGroups.set(pr, g)
prOrder.push(pr) prOrder.push(pr)
} }
+61
View File
@@ -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<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: { export async function getIssue(opts: {
host: string host: string
token: string token: string
+35
View File
@@ -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' })
})
})
+57
View File
@@ -0,0 +1,57 @@
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
})
}
+26 -1
View File
@@ -7,7 +7,11 @@
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { window, workspace } from 'vscode' 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 { gitFetch } from '../../git/branches'
import { detectRepo } from '../../git/remote'
import { import {
cherryPickToRelease, cherryPickToRelease,
cutRelease, cutRelease,
@@ -101,7 +105,28 @@ export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promis
toast(panel, 'info', '没有待摘的提交') toast(panel, 'info', '没有待摘的提交')
return 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) { catch (err) {
toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 })
+8 -16
View File
@@ -12,7 +12,6 @@ import {
normalizeReleasePrefix, normalizeReleasePrefix,
pickCurrentRelease, pickCurrentRelease,
todayReleaseBranch, todayReleaseBranch,
parsePrNumber,
groupCommitsByPr, groupCommitsByPr,
} from '../../src/git/release' } 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', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined() expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
}) })
it('parsePrNumber 认 Closes/Fixes/Resolves 和 (#N)', () => { it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => {
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([ const groups = groupCommitsByPr([
{ sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' }, { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
{ sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' }, { 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: '' }, { sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' },
]) ])
expect(groups).toEqual([ expect(groups).toEqual([
{ {
pr: 2, pr: 260,
title: 'feat: later', title: '连续包月会员订阅体系',
authors: ['me', 'you'], authors: ['chehongweo'],
commits: [ commits: [
{ sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' }, { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
{ sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' }, { sha: '5572c4f', subject: 'fix(membership): 年付月度发卡', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
], ],
}, },
{ {