diff --git a/lib/github.mjs b/lib/github.mjs index 37db350..1f07d9e 100644 --- a/lib/github.mjs +++ b/lib/github.mjs @@ -47,15 +47,32 @@ export function repository(b, r) { throw err('GITHUB_REPOSITORY_REQUIRED') } +// 按分支查 PR:state=all 才能同时命中 open 与已 merged 的历史 PR。 +// 优先 open,其次最近一条(含 merged/closed)——publish/archive 据此复用,绝不重复开 PR。 +export async function findPr(repository, branch, baseBranch) { + if (!branch) return null + const qs = new URLSearchParams({ state: 'all', head: `${repository.split('/')[0]}:${branch}`, per_page: '20' }) + const found = await api(`/repos/${repository}/pulls?${qs}`) + const list = (Array.isArray(found) ? found : []).filter(p => p && p.number && (!baseBranch || !p.base || !p.base.ref || p.base.ref === baseBranch)) + if (!list.length) return null + const stamp = p => String(p.merged_at || p.closed_at || p.created_at || '') + return list.find(p => p.state === 'open') || list.slice().sort((a, c) => stamp(c).localeCompare(stamp(a)))[0] +} + export async function pr(b, r) { const n = Number(b.data.github?.pullRequest) - if (!n) throw err('PULL_REQUEST_REQUIRED') - return api(`/repos/${repository(b, r)}/pulls/${n}`) + if (n) return api(`/repos/${repository(b, r)}/pulls/${n}`) + // brief 的 pullRequest 缺失或被早期命令回写清空时,按分支回查并回填,避免归档被 PULL_REQUEST_REQUIRED 卡死 + const found = await findPr(repository(b, r), b.data.branch, b.data.baseBranch || 'main') + if (!found) throw err('PULL_REQUEST_REQUIRED') + b.data.github = { ...(b.data.github || {}), pullRequest: found.number, pullRequestUrl: found.html_url || null } + return found } export async function ensurePr(x) { - const qs = new URLSearchParams({ state: 'open', head: `${x.repository.split('/')[0]}:${x.branch}`, base: x.baseBranch }), found = await api(`/repos/${x.repository}/pulls?${qs}`) - if (found[0]) return { pr: found[0], created: false } + // 复用而非新建:已 merged 的 PR 同样命中(此前只查 state=open,分支合并后再 publish 会重复开 PR) + const existing = await findPr(x.repository, x.branch, x.baseBranch) + if (existing) return { pr: existing, created: false } try { const z = await api(`/repos/${x.repository}/pulls`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, head: x.branch, base: x.baseBranch }) }) return { pr: z, created: true } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index b8a7fb7..3f010f0 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -684,6 +684,41 @@ test('publish pushes normally and creates a PR when none exists', () => { } finally { api.close() } }) +test('publish reuses an already merged PR instead of opening a duplicate', () => { + const root = fixture() + addOrigin(root) + execFileSync('git', ['switch', '-c', 'feat/sample'], { cwd: root }) + writeFileSync(join(root, 'feature.txt'), 'feature' + String.fromCharCode(10)) + execFileSync('git', ['add', '--', 'feature.txt'], { cwd: root }) + execFileSync('git', ['commit', '-m', 'feature'], { cwd: root }) + // 复现 2026-10-09 事故:PR 已 merged,brief 的 pullRequest 字段被复验回写清空 + updateBrief(root, data => { data.github.repository = 'owner/repo'; data.branch = 'feat/sample'; data.github.pullRequest = null; data.github.pullRequestUrl = null }) + const merged = { number: 525, state: 'closed', merged_at: '2026-10-09T11:20:00Z', html_url: 'https://github.test/pulls/525', base: { ref: 'main' } }; + const api = apiStub([{ method: 'GET', path: '/repos/owner/repo/pulls?', body: [merged] }]) + try { + const args = ['--name', 'sample', '--title', 'Feature'] + const planned = run(['publish', 'plan', ...args, '--json'], root) + const result = run(['publish', 'execute', ...args, '--plan-hash', JSON.parse(planned.stdout).planHash, '--confirm', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: api.url }) + assert.equal(result.status, 0, result.stderr) + const out = JSON.parse(result.stdout) + assert.equal(out.data.number, 525) + assert.equal(out.data.created, false) + assert.deepEqual(api.requests().map(request => request.method), ['GET']) + assert.match(readFileSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md'), 'utf8'), /"pullRequest": 525/) + } finally { api.close() } +}) + +test('pr inspect recovers the PR by branch when the brief lost its pullRequest field', () => { + const root = fixture() + updateBrief(root, data => { data.github.repository = 'owner/repo'; data.github.pullRequest = null; data.branch = 'feat/sample' }) + const found = { number: 12, state: 'closed', merged_at: '2026-10-09T00:00:00Z', html_url: 'https://github.test/pulls/12', base: { ref: 'main' } }; + const api = apiStub([{ method: 'GET', path: '/repos/owner/repo/pulls?', body: [found] }]) + try { + const result = run(['pr', 'inspect', '--name', 'sample', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: api.url }) + assert.equal(result.status, 0, result.stderr) + assert.equal(JSON.parse(result.stdout).data.number, 12) + } finally { api.close() } +}) test('publish defaults the PR body to Closes #N when the brief has an issue', () => { const root = fixture() addOrigin(root)