Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
35 changes: 35 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading