fix(pr-bot): announce a bot-made merge once, cap automatic retries, show why a review failed

Observed on the first live merge (#383 via the Telegram button): runConfirmed
announced the merge and the scan five seconds later announced it again as a
closed PR. The scan now stays quiet for PRs the bot itself merged or closed,
and a merge of a `merge-with-fixes` verdict reminds that merging applies none
of the listed fixes.

A failed review used to be re-queued on every scan with no limit (two PRs
failed once each and were retried fine, but a head that keeps failing would
cost a session every ten minutes forever): three failures on one head now
stop the automatic retries until /review N or a new push. The failure notice
carries the reviewer's last message, so "finished without writing
report.json" says what it wrote instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-06 21:09:47 +02:00
parent f33b37c008
commit 9f5010aa51
3 changed files with 56 additions and 10 deletions
+14 -2
View File
@@ -134,7 +134,7 @@ describe('PrBot commands', () => {
tg = new FakeTelegram();
bot = new PrBot(cfg, { telegram: tg, codeman: {} as CodemanClient, log: () => undefined });
const rec = bot.store.upsertPr(detail());
Object.assign(rec, { status: 'reviewed', reviewedSha: 'abc123abc123', verdict: 'merge', report });
Object.assign(rec, { status: 'reviewed', reviewedSha: 'abc123abc123', verdict: 'merge-with-fixes', report });
bot.store.save();
});
@@ -176,6 +176,18 @@ describe('PrBot commands', () => {
expect(tg.last()).toContain('no longer valid');
});
it('announces a bot-made merge once: the next scan retires the PR silently', async () => {
await bot.handleUpdate(msg('/merge 381'));
await bot.handleUpdate(cb(tg.confirmData()));
const merged = tg.sent.filter((s) => s.text.includes('Merged <b>#381</b>'));
expect(merged).toHaveLength(1);
expect(merged[0].text).toContain('fixes to apply at merge time'); // the verdict on the seeded record is merge-with-fixes below
const result = await bot.scanOnce('test'); // listOpenPrs is mocked to []: 381 is gone
expect(result.closed).toEqual([381]);
expect(bot.store.pr(381)?.closedAs).toBe('merged');
expect(tg.sent.filter((s) => s.text.includes('Merged <b>#381</b>'))).toHaveLength(1);
});
it('ignores a confirmation tap from a foreign chat', async () => {
await bot.handleUpdate(msg('/merge 381'));
await bot.handleUpdate(cb(tg.confirmData(), 2));
@@ -232,6 +244,6 @@ describe('PrBot commands', () => {
it('reports status with verdict icons', async () => {
await bot.handleUpdate(msg('/status'));
expect(tg.last()).toContain('✅ <b>#381</b>');
expect(tg.last()).toContain('🟢 <b>#381</b>');
});
});