chore: move the maintainer PR bot out of this repository

`scripts/pr-bot/` was maintainer tooling, not part of the server, the CLI or the
npm package: a Telegram bot that reviews open pull requests in Codeman sessions
and reports to the maintainer. It now lives in its own private repository and
keeps running unchanged, as a client of Codeman's HTTP API like any other.

It moved because it grew a second watcher, for GitHub Discussions, and shipping
that here would mean publishing the briefs it hands its review sessions, the
judgement calls in them and its safety model. None of that helps anyone
installing Codeman, and all of it is easier to change when it is not a public
interface. The move cost nothing structurally: the whole tree depended on one
external package plus Node builtins.

What this removes from the repo, and nothing else: the sources, their three test
files, `config/tsconfig.pr-bot.json`, `docs/pr-bot.md`, the `pr-bot` npm script,
the bot's globs in the typecheck/lint/format scripts, and its knip entry. CLAUDE.md
keeps a short pointer in place of the section, because the bot still constrains
work in here: it takes the `prbot-<n>` and `dscbot-<n>` session names on the local
Codeman, holds clones under `~/.codeman/pr-bot/`, and fetches pull-request heads
into `refs/pr-bot/*` of this checkout, which it must never check out or reset.

The CHANGELOG entries from 1.25.0 and earlier still describe it. That is history
rather than drift, and is left alone.

Verified after the removal: typecheck, lint and format:check clean, and the suite
passes 6843 tests across 357 files, which is the previous run minus exactly the
70 tests that moved out with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 15:24:58 +02:00
parent fc098aaab2
commit c2d019d956
18 changed files with 7 additions and 4235 deletions
-249
View File
@@ -1,249 +0,0 @@
/**
* @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-with-fixes', 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('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));
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>');
});
});
-402
View File
@@ -1,402 +0,0 @@
/**
* @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 { findModelLimitNotice, 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('findModelLimitNotice', () => {
// Captured off prbot-394's pane on 2026-09-08, the run that lost 40 minutes: Claude
// Code answers a spent budget inside the turn and then simply sits there.
const SPENT_PANE = [
'\x1b[38;5;153m\u276f\x1b[39m Read /home/arkon/.codeman/pr-bot/jobs/pr-394/brief.md and do the review.',
" \u23bf You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
'\u273b Saut\u00e9ed for 1s \u00b7 done 7:16 PM',
].join('\n');
it('finds the notice on a real pane, ANSI and gutter glyph stripped', () => {
expect(findModelLimitNotice(SPENT_PANE)).toBe(
"You've reached your Fable limit. Run /usage-credits to continue or switch models with /model."
);
});
it('is not tied to one model name or to a straight apostrophe', () => {
// The pane renders a typographic apostrophe, and every model prints this sentence.
expect(
findModelLimitNotice(' \u23bf You\u2019ve reached your Opus limit. Run /usage-credits to continue.')
).toContain('reached your Opus limit');
expect(findModelLimitNotice('You have reached your Sonnet 5 limit.')).toContain('Sonnet 5');
});
it('says nothing about an ordinary working pane', () => {
expect(findModelLimitNotice('\u273b Actualizing\u2026 (13m 23s \u00b7 esc to interrupt)')).toBeUndefined();
expect(findModelLimitNotice('')).toBeUndefined();
// The bare word is not the notice: a review whose own findings discuss usage limits
// must not be reported as an exhausted account.
expect(findModelLimitNotice('the usage limit parser handles the 5-hour reset')).toBeUndefined();
});
});
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
@@ -1,84 +0,0 @@
/**
* @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();
});
});