feat(pr-bot): review open PRs in Codeman sessions and report over Telegram

Maintainer tooling in scripts/pr-bot/: a daemon (systemd user unit
codeman-pr-bot) that lists open PRs with gh, reviews each head commit once in
a Codeman claude session (`prbot-<n>`) running in a private `git clone
--shared`, and sends the verdict, ranked findings, checks and a recommendation
to Telegram with action buttons. Merge, close, post-comment and approve-CI
happen only from a Telegram command or button plus a confirmation tap; the
bot never writes to GitHub on its own. The Telegram token and chat id come
from the existing notifier bot's env file.

Verified live: three PRs reviewed end to end (383, 363, 368), reports
delivered with buttons, reviewer sessions on the pinned model. Findings
along the way, each fixed and documented: a linked worktree inherits the
main checkout's model pin (hence the shared clone), undici's 5-minute header
timeout cut off the first review, gh was missing from the service PATH, and
the periodic scan orphaned an in-flight review's record.

typecheck/lint/format now cover scripts/pr-bot; tests in
test/pr-bot-{report,state,commands}.test.ts; guide in docs/pr-bot.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-06 21:05:42 +02:00
parent 097d585278
commit f33b37c008
18 changed files with 4096 additions and 6 deletions
+237
View File
@@ -0,0 +1,237 @@
/**
* @fileoverview The PR bot's Telegram command and button handling, driven through
* `PrBot.handleUpdate` with a recording Telegram stub and a mocked `gh` layer. Pins
* the one property that matters most: a GitHub write (merge, close, post) happens only
* after the confirmation tap, exactly once, and never for a foreign chat or a stale
* nonce.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const gh = vi.hoisted(() => ({
listOpenPrs: vi.fn(async () => []),
getPrDetail: vi.fn(),
getCiStatus: vi.fn(async () => ({ state: 'passed', runs: [] })),
mergePr: vi.fn(async () => 'merged'),
closePr: vi.fn(async () => 'closed'),
commentPr: vi.fn(async () => 'commented'),
approveWorkflowRun: vi.fn(async () => undefined),
gh: vi.fn(async () => 'MERGED\n'),
}));
vi.mock('../scripts/pr-bot/github.js', () => gh);
vi.mock('../scripts/pr-bot/worktree.js', () => ({
preparePrWorktree: vi.fn(),
removePrWorktree: vi.fn(async () => undefined),
}));
import { PrBot, type TelegramLike } from '../scripts/pr-bot/bot.js';
import { buildConfig } from '../scripts/pr-bot/config.js';
import type { CodemanClient } from '../scripts/pr-bot/codeman-client.js';
import type { PrDetail } from '../scripts/pr-bot/github.js';
import type { ReviewReport } from '../scripts/pr-bot/report.js';
class FakeTelegram implements TelegramLike {
sent: { text: string; markup?: unknown; plain: boolean }[] = [];
edits: number[] = [];
private nextId = 100;
isOurChat(chatId: number | string | undefined): boolean {
return String(chatId) === '1';
}
async sendMessage(text: string, opts: { replyMarkup?: unknown } = {}): Promise<number> {
this.sent.push({ text, markup: opts.replyMarkup, plain: false });
return this.nextId++;
}
async sendPlain(text: string): Promise<number> {
this.sent.push({ text, plain: true });
return this.nextId++;
}
async editReplyMarkup(messageId: number): Promise<void> {
this.edits.push(messageId);
}
async deleteMessage(): Promise<void> {}
async answerCallback(): Promise<void> {}
async sendDocument(): Promise<void> {}
async getUpdates(): Promise<[]> {
return [];
}
async setMyCommands(): Promise<void> {}
last(): string {
return this.sent[this.sent.length - 1]?.text ?? '';
}
/** The confirm callback_data of the last message's keyboard. */
confirmData(): string {
const markup = this.sent[this.sent.length - 1]?.markup as { inline_keyboard: { callback_data: string }[][] };
return markup.inline_keyboard.flat().find((b) => b.callback_data.startsWith('confirm:'))!.callback_data;
}
}
function detail(over: Partial<PrDetail> = {}): PrDetail {
return {
number: 381,
title: 'feat(web): base URL',
author: 'mtiller',
headSha: 'abc123abc123',
baseRef: 'master',
headRef: 'feat',
isDraft: false,
mergeable: 'MERGEABLE',
mergeState: 'CLEAN',
additions: 10,
deletions: 2,
changedFiles: 3,
updatedAt: '',
url: 'https://github.com/Ark0N/Codeman/pull/381',
isCrossRepository: true,
labels: [],
body: '',
files: [],
authorAssociation: 'CONTRIBUTOR',
linkedIssues: [],
commitCount: 1,
commentCount: 0,
reviewDecision: '',
headRepo: 'mtiller/Codeman',
...over,
};
}
const report: ReviewReport = {
verdict: 'merge',
confidence: 'high',
summary: 's',
changes: [],
findings: [],
checks: [],
scope: 'focused',
risk: '',
recommendation: 'merge it',
draftComment: 'Thanks, merging.',
assumptions: [],
};
const msg = (text: string, chat = 1, replyTo?: { message_id: number; text?: string }) => ({
update_id: 1,
message: { message_id: 7, chat: { id: chat }, text, reply_to_message: replyTo },
});
const cb = (data: string, chat = 1) => ({
update_id: 2,
callback_query: { id: 'q', from: { id: 1 }, data, message: { message_id: 9, chat: { id: chat } } },
});
describe('PrBot commands', () => {
let bot: PrBot;
let tg: FakeTelegram;
beforeEach(() => {
vi.clearAllMocks();
gh.getPrDetail.mockImplementation(async () => detail());
const cfg = buildConfig(
{ TELEGRAM_BOT_TOKEN: 't', TELEGRAM_CHAT_ID: '1', PR_BOT_DATA_DIR: mkdtempSync(join(tmpdir(), 'prbot-cmd-')) },
{ home: '/h', repoRoot: '/r' }
);
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 });
bot.store.save();
});
afterEach(async () => {
await bot.stop();
});
it('answers /help only for the configured chat', async () => {
await bot.handleUpdate(msg('/help', 2));
expect(tg.sent).toHaveLength(0);
await bot.handleUpdate(msg('/help'));
expect(tg.last()).toContain('/merge N');
});
it('shows the draft without posting it', async () => {
await bot.handleUpdate(msg('/draft 381'));
expect(tg.last()).toContain('Thanks, merging.');
expect(tg.last()).toContain('not posted');
expect(gh.commentPr).not.toHaveBeenCalled();
});
it('merges only after the confirmation tap, once, and rejects a reused nonce', async () => {
await bot.handleUpdate(msg('/merge 381'));
expect(gh.mergePr).not.toHaveBeenCalled();
expect(tg.last()).toContain('Merge <b>#381</b>');
expect(Object.keys(bot.store.state.pending)).toHaveLength(1);
const data = tg.confirmData();
expect(data).toMatch(/^confirm:merge:381:[0-9a-f]{8}$/);
await bot.handleUpdate(cb(data));
expect(gh.mergePr).toHaveBeenCalledTimes(1);
expect(gh.mergePr).toHaveBeenCalledWith('Ark0N/Codeman', 381);
expect(tg.last()).toContain('Merged <b>#381</b>');
expect(Object.keys(bot.store.state.pending)).toHaveLength(0);
expect(tg.edits).toContain(9); // the keyboard is removed from the confirmation message
await bot.handleUpdate(cb(data));
expect(gh.mergePr).toHaveBeenCalledTimes(1);
expect(tg.last()).toContain('no longer valid');
});
it('ignores a confirmation tap from a foreign chat', async () => {
await bot.handleUpdate(msg('/merge 381'));
await bot.handleUpdate(cb(tg.confirmData(), 2));
expect(gh.mergePr).not.toHaveBeenCalled();
});
it('refuses to offer a merge for a conflicting PR and warns about red CI', async () => {
gh.getPrDetail.mockImplementationOnce(async () => detail({ mergeable: 'CONFLICTING' }));
await bot.handleUpdate(msg('/merge 381'));
expect(tg.last()).toContain('needs a rebase');
expect(Object.keys(bot.store.state.pending)).toHaveLength(0);
gh.getCiStatus.mockImplementationOnce(async () => ({ state: 'failed', runs: [] }));
await bot.handleUpdate(msg('/merge 381'));
expect(tg.last()).toContain('CI is red');
expect(Object.keys(bot.store.state.pending)).toHaveLength(1);
});
it('cancel drops the pending confirmation', async () => {
await bot.handleUpdate(msg('/merge 381'));
const data = tg.confirmData().replace(/^confirm:/, 'cancel:');
await bot.handleUpdate(cb(data));
expect(Object.keys(bot.store.state.pending)).toHaveLength(0);
await bot.handleUpdate(cb(tg.sent[tg.sent.length - 1] ? data.replace(/^cancel:/, 'confirm:') : ''));
expect(gh.mergePr).not.toHaveBeenCalled();
});
it('closes with the given comment after confirmation, and asks for one when missing', async () => {
await bot.handleUpdate(msg('/close 381'));
expect(tg.last()).toContain('Reply to this message');
expect(gh.closePr).not.toHaveBeenCalled();
await bot.handleUpdate(msg('/close 381 superseded by #372'));
expect(tg.last()).toContain('superseded by #372');
await bot.handleUpdate(cb(tg.confirmData()));
expect(gh.closePr).toHaveBeenCalledWith('Ark0N/Codeman', 381, 'superseded by #372');
});
it('posts the draft only after confirmation', async () => {
await bot.handleUpdate(msg('/post 381'));
expect(gh.commentPr).not.toHaveBeenCalled();
expect(tg.sent.some((s) => s.plain && s.text === 'Thanks, merging.')).toBe(true);
await bot.handleUpdate(cb(tg.confirmData()));
expect(gh.commentPr).toHaveBeenCalledWith('Ark0N/Codeman', 381, 'Thanks, merging.');
});
it('a reply to a review message becomes a follow-up, refused when nothing was reviewed', async () => {
const rec = bot.store.upsertPr(detail({ number: 390, title: 'other' }));
bot.store.rememberMessage(55, 390);
expect(rec.reviewedSha).toBeUndefined();
await bot.handleUpdate(msg('does it handle X?', 1, { message_id: 55 }));
expect(tg.last()).toContain('No review of #390 yet');
});
it('reports status with verdict icons', async () => {
await bot.handleUpdate(msg('/status'));
expect(tg.last()).toContain('✅ <b>#381</b>');
});
});
+370
View File
@@ -0,0 +1,370 @@
/**
* @fileoverview Unit tests for the PR bot's pure helpers: report parsing and
* Telegram formatting (report.ts), CI classification (github.ts), command and
* callback parsing (telegram.ts), the trust-dialog reader (codeman-client.ts) and
* config validation (config.ts). No network, no git, no Telegram.
*/
import { describe, it, expect } from 'vitest';
import {
buildReportKeyboard,
confirmKeyboard,
extractJsonObject,
formatReviewFailure,
formatStatusList,
formatTelegramSummary,
orderBacklog,
parseReport,
splitTelegramMessage,
TELEGRAM_MAX,
type ReviewReport,
} from '../scripts/pr-bot/report.js';
import { classifyCi, latestRunPerWorkflow, type PrSummary, type WorkflowRun } from '../scripts/pr-bot/github.js';
import { parseCallback, parseCommand, prNumberFromMessageText } from '../scripts/pr-bot/telegram.js';
import { trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
import { buildConfig, parseEnvFile } from '../scripts/pr-bot/config.js';
import { buildReviewBrief } from '../scripts/pr-bot/review-task.js';
const pr: PrSummary = {
number: 381,
title: 'feat(web): support a reverse-proxy base URL',
author: 'mtiller',
headSha: '7e4914d991ea864d7dfbefe03f042380d02981c4',
baseRef: 'master',
headRef: 'feat/reverse-proxy-base-url',
isDraft: false,
mergeable: 'MERGEABLE',
mergeState: 'UNSTABLE',
additions: 664,
deletions: 112,
changedFiles: 28,
updatedAt: '2026-09-04T20:15:52Z',
url: 'https://github.com/Ark0N/Codeman/pull/381',
isCrossRepository: true,
labels: [],
};
const rawReport = {
verdict: 'request_changes',
confidence: 'HIGH',
summary: 'Adds a base path. Two real bugs.',
changes: ['base-path config', 'ingress rewrite'],
findings: [
{ severity: 'minor', title: 'nit first in input', file: 'a.ts', line: 1, detail: 'x' },
{ severity: 'blocker', title: 'SSE path not prefixed', file: 'src/web/server.ts', line: 210, detail: 'events 404' },
{ severity: 'bogus', title: 'unknown severity becomes minor', detail: '' },
{ title: '' },
],
checks: [
{ name: 'typecheck', command: 'npm run typecheck', result: 'PASS' },
{ name: 'tests', result: 'fail', notes: '2 failed' },
{ name: '', result: 'pass' },
],
scope: 'MIXED',
risk: 'r',
recommendation: 'Ask for the SSE fix, then merge.',
draftComment: 'Thanks!',
assumptions: ['none', 42],
};
describe('parseReport', () => {
it('normalizes case, separators and severities, and sorts findings by severity', () => {
const r = parseReport(rawReport)!;
expect(r.verdict).toBe('request-changes');
expect(r.confidence).toBe('high');
expect(r.scope).toBe('mixed');
expect(r.findings.map((f) => f.severity)).toEqual(['blocker', 'minor', 'minor']);
expect(r.findings[0].file).toBe('src/web/server.ts');
expect(r.findings[0].line).toBe(210);
expect(r.checks).toHaveLength(2);
expect(r.checks[0].result).toBe('pass');
expect(r.assumptions).toEqual(['none']);
});
it('returns null without a recognizable verdict', () => {
expect(parseReport({ summary: 'no verdict' })).toBeNull();
expect(parseReport(null)).toBeNull();
expect(parseReport('merge')).toBeNull();
});
it('defaults confidence to medium', () => {
expect(parseReport({ verdict: 'merge' })!.confidence).toBe('medium');
});
});
describe('extractJsonObject', () => {
it('reads bare JSON, fenced JSON and JSON inside prose', () => {
expect(extractJsonObject('{"verdict":"merge"}')).toEqual({ verdict: 'merge' });
expect(extractJsonObject('Here:\n```json\n{"verdict":"close"}\n```\nDone.')).toEqual({ verdict: 'close' });
expect(extractJsonObject('REVIEW COMPLETE {"verdict":"merge","x":1} trailing')).toEqual({ verdict: 'merge', x: 1 });
expect(extractJsonObject('nothing here')).toBeNull();
});
});
describe('formatTelegramSummary', () => {
const report = parseReport(rawReport)!;
it('carries the verdict, the top findings, checks and the recommendation, HTML-escaped', () => {
const text = formatTelegramSummary(pr, report, { ci: 'awaiting-approval', durationMin: 7 });
expect(text).toContain('PR #381');
expect(text).toContain('REQUEST CHANGES');
expect(text).toContain('needs your approval');
expect(text).toContain('🔴 SSE path not prefixed');
expect(text).toContain('src/web/server.ts:210');
expect(text).toContain('typecheck ✅');
expect(text).toContain('tests ❌');
expect(text).toContain('Ask for the SSE fix');
expect(text).toContain('review took 7 min');
expect(text.length).toBeLessThan(TELEGRAM_MAX);
});
it('escapes HTML in model output', () => {
const r: ReviewReport = { ...report, summary: 'uses <script> & friends', findings: [] };
const text = formatTelegramSummary(pr, r, { ci: 'passed' });
expect(text).toContain('uses &lt;script&gt; &amp; friends');
expect(text).not.toContain('<script>');
});
it('stays under the Telegram cap with many long findings and says how many are hidden', () => {
const findings = Array.from({ length: 60 }, (_, i) => ({
severity: 'major' as const,
title: `finding ${i} ${'x'.repeat(150)}`,
file: `src/file-${i}.ts`,
line: i,
detail: 'd',
}));
const text = formatTelegramSummary(pr, { ...report, findings }, { ci: 'failed' });
expect(text.length).toBeLessThanOrEqual(TELEGRAM_MAX);
expect(text).toMatch(/… \d+ more in the full report/);
});
});
describe('splitTelegramMessage', () => {
it('keeps short text whole and splits long text on line boundaries', () => {
expect(splitTelegramMessage('a\nb')).toEqual(['a\nb']);
const lines = Array.from({ length: 300 }, (_, i) => `line ${i} ${'y'.repeat(40)}`);
const chunks = splitTelegramMessage(lines.join('\n'));
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(TELEGRAM_MAX);
expect(chunks.join('\n')).toBe(lines.join('\n'));
});
it('hard-splits a single line longer than the cap', () => {
const chunks = splitTelegramMessage('z'.repeat(9000), 4000);
expect(chunks.map((c) => c.length)).toEqual([4000, 4000, 1000]);
});
});
describe('keyboards', () => {
it("keeps every callback_data under Telegram's 64-byte cap and adds the approve button only when CI waits", () => {
const all = [
...buildReportKeyboard(38199, { ci: 'awaiting-approval', hasDraft: true }).flat(),
...buildReportKeyboard(1, { ci: 'passed', hasDraft: false }).flat(),
...confirmKeyboard('merge', 38199, 'deadbeef').flat(),
];
for (const b of all) expect(Buffer.byteLength(b.callback_data)).toBeLessThanOrEqual(64);
expect(
buildReportKeyboard(5, { ci: 'awaiting-approval', hasDraft: true })
.flat()
.some((b) => b.callback_data === 'approveci:5')
).toBe(true);
expect(
buildReportKeyboard(5, { ci: 'passed', hasDraft: true })
.flat()
.some((b) => b.callback_data.startsWith('approveci'))
).toBe(false);
expect(
buildReportKeyboard(5, { ci: 'passed', hasDraft: false })
.flat()
.some((b) => b.callback_data === 'post:5')
).toBe(false);
});
});
describe('orderBacklog', () => {
it('puts mergeable and small first, conflicting last, newer first on ties', () => {
const rows = [
{ number: 375, mergeable: 'CONFLICTING' as const, additions: 5000, deletions: 100 },
{ number: 383, mergeable: 'MERGEABLE' as const, additions: 29, deletions: 1 },
{ number: 380, mergeable: 'MERGEABLE' as const, additions: 1800, deletions: 400 },
{ number: 362, mergeable: 'CONFLICTING' as const, additions: 200, deletions: 10 },
{ number: 390, mergeable: 'UNKNOWN' as const, additions: 29, deletions: 1 },
];
expect(orderBacklog(rows).map((r) => r.number)).toEqual([390, 383, 380, 362, 375]);
});
});
describe('formatStatusList + formatReviewFailure', () => {
it('renders rows with verdict icons and flags', () => {
const text = formatStatusList(
[
{
number: 1,
title: 'a',
author: 'x',
verdict: 'merge',
status: 'reviewed',
ci: 'passed',
mergeable: 'MERGEABLE',
isDraft: false,
},
{ number: 2, title: 'b <c>', author: 'y', status: 'queued', mergeable: 'CONFLICTING', isDraft: true },
],
true
);
expect(text).toContain('⏸ auto-review paused');
expect(text).toContain('✅ <b>#1</b>');
expect(text).toContain('🕓 <b>#2</b> b &lt;c&gt;');
expect(text).toContain('conflicts, draft');
expect(formatStatusList([], false)).toBe('No open pull requests.');
});
it('failure notice names the retry command', () => {
expect(formatReviewFailure(pr, 'timed out')).toContain('/review 381');
});
});
describe('classifyCi', () => {
const run = (over: Partial<WorkflowRun>): WorkflowRun => ({
id: 1,
name: 'CI',
status: 'completed',
conclusion: 'success',
...over,
});
it('reads the newest run per workflow only', () => {
const runs = [
run({ id: 3, conclusion: 'success' }),
run({ id: 2, conclusion: 'failure' }),
run({ id: 1, name: 'Other', conclusion: 'failure' }),
];
expect(latestRunPerWorkflow(runs).map((r) => r.id)).toEqual([3, 1]);
expect(classifyCi(runs)).toBe('failed');
expect(classifyCi([run({ id: 3 }), run({ id: 2, conclusion: 'failure' })])).toBe('passed');
});
it('maps the fork-PR approval gate, pending and empty cases', () => {
expect(classifyCi([])).toBe('none');
expect(classifyCi([run({ conclusion: 'action_required' })])).toBe('awaiting-approval');
expect(classifyCi([run({ status: 'in_progress', conclusion: null })])).toBe('pending');
expect(classifyCi([run({ conclusion: 'skipped' })])).toBe('passed');
});
});
describe('telegram parsers', () => {
it('parses commands with and without a PR number, and bot-suffixed commands', () => {
expect(parseCommand('/merge 381')).toEqual({ command: 'merge', prNumber: 381, rest: '' });
expect(parseCommand('/close #381 superseded by #372')).toEqual({
command: 'close',
prNumber: 381,
rest: 'superseded by #372',
});
expect(parseCommand('/ask 12 does it handle\nmultiline?')).toEqual({
command: 'ask',
prNumber: 12,
rest: 'does it handle\nmultiline?',
});
expect(parseCommand('/status@arkon85_bot')).toEqual({ command: 'status', rest: '' });
expect(parseCommand('hello')).toBeNull();
expect(parseCommand(undefined)).toBeNull();
});
it('parses callbacks and rejects malformed data', () => {
expect(parseCallback('merge:381')).toEqual({ action: 'merge', prNumber: 381 });
expect(parseCallback('confirm:merge:381:ab12')).toEqual({
action: 'confirm',
target: 'merge',
prNumber: 381,
nonce: 'ab12',
});
expect(parseCallback('confirm:merge:381')).toBeNull();
expect(parseCallback('merge:x')).toBeNull();
expect(parseCallback(undefined)).toBeNull();
});
it('finds the PR number in a report message', () => {
expect(prNumberFromMessageText('🔍 PR #381 · title')).toBe(381);
expect(prNumberFromMessageText('no number')).toBeNull();
});
});
describe('trustDialogKey', () => {
it('reads the highlighted option off a tmux repaint that lost its spaces', () => {
const esc = '\x1b';
const screen = `Security guide\n${esc}[1m❯${esc}[CNo,${esc}[Cexit\n Yes, I trust this folder\nEnter to confirm`;
expect(trustDialogKey(screen)).toBe('move');
expect(trustDialogKey(' No, exit\n❯ Yes, I trust this folder\n')).toBe('confirm');
expect(trustDialogKey('❯ Try "fix the bug"\n shift+tab to cycle')).toBeNull();
});
it('lets the freshest marked row win', () => {
expect(trustDialogKey('❯ No, exit\n...\n❯ Yes, I trust this folder')).toBe('confirm');
});
});
describe('config', () => {
it('parses env files with quotes, comments and export prefixes', () => {
const env = parseEnvFile('# c\nexport A="x y"\nB=\'z\'\nC=plain\nbad line\n=nokey\n');
expect(env).toEqual({ A: 'x y', B: 'z', C: 'plain' });
});
it('validates required keys and derives paths and units', () => {
expect(() => buildConfig({}, { home: '/h', repoRoot: '/r' })).toThrow(/TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID/);
const cfg = buildConfig(
{
TELEGRAM_BOT_TOKEN: 't',
TELEGRAM_CHAT_ID: '1',
PR_BOT_POLL_INTERVAL: '30',
PR_BOT_REVIEW_TIMEOUT: '3',
PR_BOT_AUTO_REVIEW: 'off',
},
{ home: '/h', repoRoot: '/r' }
);
expect(cfg.githubRepo).toBe('Ark0N/Codeman');
expect(cfg.codemanApiUrl).toBe('https://127.0.0.1:3000');
expect(cfg.dataDir).toBe('/h/.codeman/pr-bot');
expect(cfg.worktreesDir).toBe('/h/.codeman/pr-bot/worktrees');
expect(cfg.mainCheckout).toBe('/r');
expect(cfg.pollIntervalMs).toBe(60_000); // floored at 60s
expect(cfg.reviewTimeoutMs).toBe(5 * 60_000); // floored at 5 min
expect(cfg.autoReview).toBe(false);
expect(cfg.reviewDrafts).toBe(false);
expect(() =>
buildConfig(
{ TELEGRAM_BOT_TOKEN: 't', TELEGRAM_CHAT_ID: '1', GITHUB_REPO: 'nope' },
{ home: '/h', repoRoot: '/r' }
)
).toThrow(/owner\/name/);
});
});
describe('buildReviewBrief', () => {
it('names the report paths, the ground rules and the CI situation', () => {
const brief = buildReviewBrief({
pr: {
...pr,
body: 'Body **md**',
files: [{ path: 'src/a.ts', additions: 1, deletions: 0 }],
authorAssociation: 'FIRST_TIME_CONTRIBUTOR',
linkedIssues: [],
commitCount: 2,
commentCount: 0,
reviewDecision: '',
headRepo: 'mtiller/Codeman',
},
ci: { state: 'awaiting-approval', runs: [] },
mergeBase: 'abcdef0123456789',
worktreeDir: '/wt/pr-381',
mainCheckout: '/main',
reportJsonPath: '/jobs/report.json',
reportMdPath: '/jobs/report.md',
});
expect(brief).toContain('/jobs/report.json');
expect(brief).toContain('/jobs/report.md');
expect(brief).toContain('REVIEW COMPLETE');
expect(brief).toContain('waiting for a maintainer to approve');
expect(brief).toContain('never bind port 3000');
expect(brief).toContain('first time contributor');
expect(brief).toContain('`src/a.ts` (+1/-0)');
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* @fileoverview StateStore semantics for the PR bot: upsert keeps review results
* across scans, a closed PR that reopens comes back as reviewed, saves are atomic
* and 0600, and the message map is bounded.
*/
import { describe, it, expect } from 'vitest';
import { mkdtempSync, readdirSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { StateStore } from '../scripts/pr-bot/state.js';
import type { PrSummary } from '../scripts/pr-bot/github.js';
function summary(over: Partial<PrSummary> = {}): PrSummary {
return {
number: 7,
title: 't',
author: 'a',
headSha: 'aaaa',
baseRef: 'master',
headRef: 'x',
isDraft: false,
mergeable: 'MERGEABLE',
mergeState: 'CLEAN',
additions: 1,
deletions: 1,
changedFiles: 1,
updatedAt: '',
url: 'https://example/7',
isCrossRepository: true,
labels: [],
...over,
};
}
describe('StateStore', () => {
it('starts empty, persists, and reloads', () => {
const dir = mkdtempSync(join(tmpdir(), 'prbot-state-'));
const path = join(dir, 'state.json');
const store = new StateStore(path);
const rec = store.upsertPr(summary());
expect(rec.status).toBe('new');
rec.status = 'reviewed';
rec.reviewedSha = 'aaaa';
rec.verdict = 'merge';
store.state.telegramOffset = 42;
store.save();
expect(statSync(path).mode & 0o777).toBe(0o600);
expect(readdirSync(dir)).toEqual(['state.json']); // no tmp file left behind
const again = new StateStore(path);
expect(again.pr(7)?.verdict).toBe('merge');
expect(again.state.telegramOffset).toBe(42);
});
it('upsert refreshes metadata but keeps the review; reopening a closed PR restores reviewed', () => {
const store = new StateStore(join(mkdtempSync(join(tmpdir(), 'prbot-state-')), 'state.json'));
const rec = store.upsertPr(summary());
rec.status = 'reviewed';
rec.reviewedSha = 'aaaa';
const moved = store.upsertPr(summary({ headSha: 'bbbb', title: 'renamed' }));
expect(moved).toBe(rec); // same object: a review in flight keeps writing into the stored record
expect(moved.status).toBe('reviewed');
expect(moved.reviewedSha).toBe('aaaa');
expect(moved.headSha).toBe('bbbb');
expect(moved.title).toBe('renamed');
moved.status = 'closed';
moved.closedAs = 'closed';
expect(store.openPrs()).toHaveLength(0);
const reopened = store.upsertPr(summary({ headSha: 'bbbb' }));
expect(reopened.status).toBe('reviewed');
expect(reopened.closedAs).toBeUndefined();
expect(store.openPrs()).toHaveLength(1);
});
it('bounds the message map on save', () => {
const store = new StateStore(join(mkdtempSync(join(tmpdir(), 'prbot-state-')), 'state.json'));
for (let i = 0; i < 2500; i++) store.rememberMessage(i, 1);
store.save();
const keys = Object.keys(store.state.messages).map(Number);
expect(keys).toHaveLength(2000);
expect(Math.min(...keys)).toBe(500);
expect(store.prForMessage(2499)).toBe(1);
expect(store.prForMessage(10)).toBeUndefined();
});
});