diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index 9f8b146..352a8b8 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -26,7 +26,7 @@ export interface UnreleasedCommit { author: string date: string body: string - /** Gitea 查到的所属 PR;没有就不分组。 */ + /** 从合并记录认出的所属 PR;没有就不分组。 */ pr?: number prTitle?: string /** 带 alembic 迁移文件时才有;blocked 有值就不能摘。 */ @@ -114,12 +114,28 @@ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): st return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1) } -/** 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 号和标题,认不出返回 `undefined`。 + * + * 认三种默认说明:Gitea 合并提交 `Merge pull request '标题' (#260) from …`;GitHub 合并提交 `Merge pull request #260 from owner/分支`, + * 标题在说明正文第一行,正文为空就用来源分支;squash 合并的 `标题 (#260)`,两家都是这个格式。 + * + * @param subject 提交说明的标题行 + * @param body 提交说明正文(标题行之后的部分),只有 GitHub 合并提交用得上 + */ +export function parseMergeSubject(subject: string, body = ''): { number: number, title: string } | undefined { + const gitea = /^Merge pull request '(.+)' \(#(\d+)\)/.exec(subject) + if (gitea) + return { title: gitea[1], number: Number(gitea[2]) } + const github = /^Merge pull request #(\d+) from (\S+)/.exec(subject) + if (github) { + const title = body.split('\n').map(line => line.trim()).find(Boolean) + return { title: title ?? github[2], number: Number(github[1]) } + } + const squash = /^(.+) \(#(\d+)\)$/.exec(subject) + if (squash) + return { title: squash[1], number: Number(squash[2]) } + return undefined } /** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自合并记录,不是扫提交说明。 */ @@ -202,29 +218,27 @@ export async function listReleaseBranches(o: { cwd: string, prefix: string }): P return names.filter(b => b.startsWith(o.prefix)).sort().reverse() } -/** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */ +/** 用本地的合并记录把未发布提交对上 PR:合并提交带进来的提交归那个 PR,squash 合并的提交看它自己的说明;不调代码托管接口。 */ export async function indexUnreleasedByMergePr(o: { cwd: string current: string devBranch: string - shas: readonly string[] + commits: readonly { sha: string, subject: string }[] }): Promise> { - const needed = new Set(o.shas) + const needed = new Set(o.commits.map(c => c.sha)) 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)], + ['log', '--right-only', '--cherry-pick', '--merges', '--format=%H%x00%s%x00%b%x1e', unreleasedRange(o.current, o.devBranch)], '读取合并记录失败', ) - if (!raw) - return out - for (const line of raw.split('\n')) { - const [sha, subject] = line.split('\0') + for (const chunk of raw ? raw.split('\x1E') : []) { + const [sha, subject, body = ''] = chunk.replace(/^\n+/, '').split('\0') if (!sha || !subject) continue - const parsed = parseGiteaMergeSubject(subject) + const parsed = parseMergeSubject(subject, body) if (!parsed) continue const members = await mustGit( @@ -239,6 +253,14 @@ export async function indexUnreleasedByMergePr(o: { if (out.size === needed.size) return out } + // squash 合并没有合并提交,PR 号只在提交自己的说明里。 + for (const c of o.commits) { + if (out.has(c.sha)) + continue + const parsed = parseMergeSubject(c.subject) + if (parsed) + out.set(c.sha, parsed) + } return out } diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index d395a30..02443ec 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -131,7 +131,7 @@ export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promis cwd: root, current, devBranch: s.devBranch, - shas: commits.map(c => c.sha), + commits, }) panel.postMessage({ type: 'release/pick-show', diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index c696d49..6716fed 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -13,7 +13,7 @@ import { listUnreleasedCommits, normalizeReleasePrefix, parseAlembicRevision, - parseGiteaMergeSubject, + parseMergeSubject, pickCurrentRelease, todayReleaseBranch, } from '../../src/git/release' @@ -66,12 +66,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('parseGiteaMergeSubject 认 Gitea 合并说明', () => { - expect(parseGiteaMergeSubject('Merge pull request \'连续包月会员订阅体系\' (#260) from feature/monthly-membership into main')).toEqual({ + it('parseMergeSubject 认 Gitea 合并说明', () => { + expect(parseMergeSubject('Merge pull request \'连续包月会员订阅体系\' (#260) from feature/monthly-membership into main')).toEqual({ number: 260, title: '连续包月会员订阅体系', }) - expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined() + expect(parseMergeSubject('fix: no merge')).toBeUndefined() + }) + it('parseMergeSubject 认 GitHub 合并说明,标题取说明正文第一行,没有正文就用来源分支', () => { + expect(parseMergeSubject('Merge pull request #12 from cruldra/feature/x', '\n会员订阅\n\n细节')).toEqual({ number: 12, title: '会员订阅' }) + expect(parseMergeSubject('Merge pull request #12 from cruldra/feature/x')).toEqual({ number: 12, title: 'cruldra/feature/x' }) + }) + it('parseMergeSubject 认 squash 合并的「标题 (#N)」', () => { + expect(parseMergeSubject('feat: 会员订阅 (#34)')).toEqual({ number: 34, title: 'feat: 会员订阅' }) + expect(parseMergeSubject('fix (#34) 不在结尾')).toBeUndefined() }) it('parseAlembicRevision 认裸赋值、带类型标注、元组和 None', () => { expect(parseAlembicRevision('revision = \'a1\'\ndown_revision = \'a0\'\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] }) @@ -208,11 +216,32 @@ describe('集成', () => { cwd: fx.repo, current: 'release/2026-08-31', devBranch: 'main', - shas: [a, b], + commits: [{ sha: a, subject: 'fix(admin): 会员档位隐藏' }, { sha: b, subject: 'fix(membership): 年付月度发卡' }], }) expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) }) + it('indexUnreleasedByMergePr 认 GitHub 的合并提交和 squash 提交', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 }) + git(fx.peer, ['checkout', '-b', 'feature']) + const a = commit(fx.peer, 'b.txt', 'b', 'fix: 档位') + git(fx.peer, ['checkout', 'main']) + git(fx.peer, ['merge', '--no-ff', '-m', 'Merge pull request #12 from cruldra/feature', '-m', '会员订阅', 'feature']) + const squashed = commit(fx.peer, 'c.txt', 'c', 'feat: 发卡 (#13)') + const lone = commit(fx.peer, 'd.txt', 'd', 'chore: lone') + 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', + commits: [{ sha: lone, subject: 'chore: lone' }, { sha: squashed, subject: 'feat: 发卡 (#13)' }, { sha: a, subject: 'fix: 档位' }], + }) + expect(map.get(a)).toEqual({ number: 12, title: '会员订阅' }) + expect(map.get(squashed)).toEqual({ number: 13, title: 'feat: 发卡' }) + expect(map.has(lone)).toBe(false) + }) it('发布分支把同一改动拆成两条,即使标题不同也不再列出', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })