/**
* @fileoverview The PR bot: polls the repository's open pull requests, reviews each
* one (once per head commit) in a Codeman claude session running in a private
* clone, and reports to the maintainer over Telegram with a verdict, the ranked
* findings, a recommendation and action buttons.
*
* Three rules shape everything here:
*
* 1. Reviews are automatic; GitHub WRITES are not. Merge, close, post-comment and
* approve-CI happen only from an explicit Telegram command or button press from
* the configured chat, and merge/close/post take a second confirmation tap. The
* bot never posts a review comment on its own: the draft is shown first and the
* maintainer decides.
* 2. One review session at a time (`reviewLoop` is serial); follow-up questions run
* beside it, at most two, never on a PR whose session is live (`busy`).
* 3. The bot deletes only sessions it created (`prbot-*`, tracked by id), and touches
* git only through worktree.ts, never the maintainer's checkout.
*/
import { randomBytes } from 'crypto';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { CodemanClient, stripAnsi, type TurnOutcome } from './codeman-client.js';
import type { PrBotConfig } from './config.js';
import {
approveWorkflowRun,
closePr,
commentPr,
getCiStatus,
getPrDetail,
gh,
listOpenPrs,
mergePr,
type CiStatus,
type PrSummary,
} from './github.js';
import {
buildReportKeyboard,
confirmKeyboard,
escapeHtml,
extractJsonObject,
formatReviewFailure,
formatStatusList,
formatTelegramSummary,
orderBacklog,
parseReport,
splitTelegramMessage,
type ReviewReport,
} from './report.js';
import { buildFollowupBrief, buildReviewBrief, followupKickoffLine, reviewKickoffLine } from './review-task.js';
import { StateStore, type PendingConfirm, type PrRecord } from './state.js';
import {
parseCallback,
parseCommand,
prNumberFromMessageText,
type TelegramCallbackQuery,
type TelegramClient,
type TelegramMessage,
type TelegramUpdate,
} from './telegram.js';
import { preparePrWorktree, removePrWorktree } from './worktree.js';
/** What the bot needs from Telegram; `main.ts review --no-telegram` substitutes a console. */
export type TelegramLike = Pick<
TelegramClient,
| 'isOurChat'
| 'sendMessage'
| 'sendPlain'
| 'editReplyMarkup'
| 'deleteMessage'
| 'answerCallback'
| 'sendDocument'
| 'getUpdates'
| 'setMyCommands'
>;
export interface PrBotDeps {
telegram: TelegramLike;
codeman: CodemanClient;
log: (msg: string) => void;
}
const CONFIRM_TTL_MS = 15 * 60_000;
/** A head that failed this many times is left alone until /review N or a new push. */
const MAX_AUTO_RETRIES = 3;
const MAX_FOLLOWUPS = 2;
const REPORT_INLINE_MAX = 3000;
const COMMANDS = [
{ command: 'status', description: 'Open PRs with verdicts' },
{ command: 'scan', description: 'Check GitHub now' },
{ command: 'review', description: '/review N: (re)review a PR now' },
{ command: 'report', description: '/report N: the full review' },
{ command: 'summary', description: '/summary N: the review message again' },
{ command: 'draft', description: '/draft N: the draft comment' },
{ command: 'post', description: '/post N: post the draft comment (asks first)' },
{ command: 'merge', description: '/merge N: merge (asks first)' },
{ command: 'close', description: '/close N reason: close with a comment (asks first)' },
{ command: 'approve', description: '/approve N: approve a waiting CI run' },
{ command: 'ask', description: '/ask N question: ask the reviewer' },
{ command: 'pause', description: 'Stop auto-reviewing' },
{ command: 'resume', description: 'Resume auto-reviewing' },
{ command: 'help', description: 'All commands' },
];
const HELP = `Codeman PR bot
Every open PR is reviewed once per head commit in its own Codeman session; you get the verdict here and decide.
/status · open PRs and verdicts
/scan · check GitHub now
/review N · (re)review now, jumps the queue
/report N · full review as a file
/summary N · the review message with its buttons again
/draft N · the comment drafted for the contributor
/post N · post that draft (you confirm first)
/merge N · merge with a merge commit (you confirm first)
/close N reason · close with that comment (you confirm first)
/approve N · approve a CI run waiting on you (first-time contributors)
/ask N question · ask the reviewer session anything; it resumes with its context
/pause · /resume · auto-review on and off
Reply to any review message with plain text to ask about that PR.`;
function isBotAuthor(login: string): boolean {
return login.endsWith('[bot]') || login.startsWith('app/');
}
function errText(err: unknown): string {
const e = err as { stderr?: string; message?: string };
const stderr = typeof e.stderr === 'string' ? e.stderr.trim() : '';
return (stderr || e.message || String(err)).slice(0, 1500);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class PrBot {
readonly store: StateStore;
private reviewQueue: number[] = [];
private readonly busy = new Set();
private readonly createdSessions = new Set();
/** PRs the bot itself merged or closed: the scan's close notice would repeat what runConfirmed already said. */
private readonly selfClosed = new Set();
private followupsRunning = 0;
private stopped = false;
private scanning = false;
private scanTimer?: NodeJS.Timeout;
private wakeQueue: (() => void) | null = null;
private reviewing: number | null = null;
constructor(
private readonly cfg: PrBotConfig,
private readonly deps: PrBotDeps
) {
mkdirSync(cfg.dataDir, { recursive: true });
this.store = new StateStore(join(cfg.dataDir, 'state.json'));
}
private get telegram(): TelegramLike {
return this.deps.telegram;
}
private get codeman(): CodemanClient {
return this.deps.codeman;
}
private log(msg: string): void {
this.deps.log(msg);
}
// ---- lifecycle -----------------------------------------------------------
async start(): Promise {
await this.telegram.setMyCommands(COMMANDS).catch((err) => this.log(`setMyCommands: ${errText(err)}`));
await this.sweepStaleSessions();
const open = this.store.openPrs().length;
await this.telegram
.sendMessage(
`🤖 PR bot online · ${escapeHtml(this.cfg.githubRepo)} · auto-review ${
this.cfg.autoReview && !this.store.state.paused ? 'on' : 'off'
} · polling every ${Math.round(this.cfg.pollIntervalMs / 60_000)} min${open ? ` · ${open} PRs known` : ''}`
)
.catch((err) => this.log(`startup message: ${errText(err)}`));
void this.reviewLoop();
void this.telegramLoop();
this.scheduleScan(2000);
}
async stop(): Promise {
this.stopped = true;
if (this.scanTimer) clearTimeout(this.scanTimer);
this.wakeQueue?.();
for (const id of this.createdSessions) {
await this.codeman.deleteSession(id).catch((err) => this.log(`delete ${id}: ${errText(err)}`));
}
this.createdSessions.clear();
for (const rec of Object.values(this.store.state.prs)) {
if (rec.status === 'reviewing') rec.status = rec.reviewedSha ? 'reviewed' : 'new';
rec.activeSessionId = undefined;
}
this.store.save();
}
/** A crashed run can leave `prbot-*` sessions behind; they are ours by construction. */
private async sweepStaleSessions(): Promise {
try {
const sessions = await this.codeman.listSessions();
for (const s of sessions) {
if (!s.name?.startsWith('prbot-')) continue;
this.log(`sweeping stale session ${s.name} (${s.id.slice(0, 8)})`);
await this.codeman.deleteSession(s.id).catch((err) => this.log(`sweep ${s.id}: ${errText(err)}`));
}
} catch (err) {
this.log(`session sweep skipped: ${errText(err)}`);
}
}
private scheduleScan(delayMs: number): void {
if (this.stopped) return;
if (this.scanTimer) clearTimeout(this.scanTimer);
this.scanTimer = setTimeout(() => {
void this.scanOnce('timer')
.catch((err) => this.log(`scan failed: ${errText(err)}`))
.finally(() => this.scheduleScan(this.cfg.pollIntervalMs));
}, delayMs);
}
// ---- scanning ------------------------------------------------------------
/** List open PRs, retire the ones that closed, queue what needs a (re)review. */
async scanOnce(reason: string): Promise<{ queued: number[]; closed: number[] }> {
if (this.scanning) return { queued: [], closed: [] };
this.scanning = true;
const queued: number[] = [];
const closed: number[] = [];
try {
const open = await listOpenPrs(this.cfg.githubRepo);
const openNumbers = new Set(open.map((p) => p.number));
for (const rec of this.store.openPrs()) {
if (openNumbers.has(rec.number) || this.busy.has(rec.number)) continue;
await this.onPrClosed(rec);
closed.push(rec.number);
}
const candidates: PrSummary[] = [];
for (const pr of open) {
const rec = this.store.upsertPr(pr);
if (isBotAuthor(pr.author)) {
rec.status = 'skipped';
continue;
}
if (pr.isDraft && !this.cfg.reviewDrafts) {
if (rec.status === 'new') rec.status = 'skipped';
continue;
}
if (rec.status === 'skipped') rec.status = rec.reviewedSha ? 'reviewed' : 'new';
const needsReview = rec.reviewedSha !== pr.headSha;
const gaveUp = (rec.failedAttempts ?? 0) >= MAX_AUTO_RETRIES && rec.failedSha === pr.headSha;
if (needsReview && !gaveUp && !this.busy.has(pr.number) && !this.reviewQueue.includes(pr.number))
candidates.push(pr);
}
if (this.cfg.autoReview && !this.store.state.paused) {
for (const pr of orderBacklog(candidates)) {
this.enqueueReview(pr.number, { front: false });
queued.push(pr.number);
}
}
this.store.save();
this.log(`scan (${reason}): ${open.length} open, ${queued.length} queued, ${closed.length} closed`);
} finally {
this.scanning = false;
}
return { queued, closed };
}
private async onPrClosed(rec: PrRecord): Promise {
let merged = false;
try {
const out = await gh([
'pr',
'view',
String(rec.number),
'--repo',
this.cfg.githubRepo,
'--json',
'state',
'--jq',
'.state',
]);
merged = out.trim() === 'MERGED';
} catch (err) {
this.log(`state lookup for #${rec.number}: ${errText(err)}`);
}
rec.status = 'closed';
rec.closedAs = merged ? 'merged' : 'closed';
rec.activeSessionId = undefined;
this.reviewQueue = this.reviewQueue.filter((n) => n !== rec.number);
await removePrWorktree({
mainCheckout: this.cfg.mainCheckout,
worktreesDir: this.cfg.worktreesDir,
prNumber: rec.number,
log: (m) => this.log(`[#${rec.number}] ${m}`),
}).catch((err) => this.log(`worktree cleanup #${rec.number}: ${errText(err)}`));
if (this.selfClosed.delete(rec.number)) return; // announced by runConfirmed already
await this.telegram
.sendMessage(
`${merged ? '🎉 Merged' : '🔒 Closed'} #${rec.number} · ${escapeHtml(rec.title)} (${escapeHtml(rec.author)})`
)
.catch((err) => this.log(`close notice: ${errText(err)}`));
}
// ---- review queue --------------------------------------------------------
enqueueReview(number: number, opts: { front: boolean }): 'queued' | 'moved' | 'busy' {
if (this.busy.has(number)) return 'busy';
const rec = this.store.pr(number);
if (rec) rec.status = 'queued';
const idx = this.reviewQueue.indexOf(number);
if (idx >= 0) {
if (!opts.front) return 'queued';
this.reviewQueue.splice(idx, 1);
this.reviewQueue.unshift(number);
this.wakeQueue?.();
return 'moved';
}
if (opts.front) this.reviewQueue.unshift(number);
else this.reviewQueue.push(number);
this.wakeQueue?.();
return 'queued';
}
private async reviewLoop(): Promise {
while (!this.stopped) {
const next = this.reviewQueue.shift();
if (next === undefined) {
await new Promise((resolve) => {
this.wakeQueue = resolve;
});
this.wakeQueue = null;
continue;
}
if (this.busy.has(next)) continue;
this.busy.add(next);
this.reviewing = next;
try {
await this.reviewPr(next);
} catch (err) {
this.log(`[#${next}] review crashed: ${errText(err)}`);
} finally {
this.busy.delete(next);
this.reviewing = null;
}
}
}
/** One full review of a PR at its current head. Serial by construction (see reviewLoop). */
async reviewPr(number: number): Promise {
const log = (m: string) => this.log(`[#${number}] ${m}`);
const started = Date.now();
let rec = this.store.pr(number);
let sessionId: string | undefined;
let progressMsg: number | undefined;
try {
const detail = await getPrDetail(this.cfg.githubRepo, number);
rec = this.store.upsertPr(detail);
const ci = await getCiStatus(this.cfg.githubRepo, detail.headSha);
rec.ci = ci.state;
rec.status = 'reviewing';
rec.lastError = undefined;
this.store.save();
progressMsg = await this.telegram.sendMessage(
`🔍 Reviewing PR #${number} · ${escapeHtml(detail.title)} (${escapeHtml(detail.author)}, +${detail.additions}/−${detail.deletions}, ${detail.changedFiles} files) …`
);
const wt = await preparePrWorktree({
mainCheckout: this.cfg.mainCheckout,
worktreesDir: this.cfg.worktreesDir,
prNumber: number,
reset: true,
log,
});
rec.worktreeDir = wt.dir;
if (wt.headSha !== detail.headSha) log(`head moved during fetch: reviewing ${wt.headSha.slice(0, 8)}`);
detail.headSha = wt.headSha;
const jobDir = join(this.cfg.dataDir, 'jobs', `pr-${number}`);
mkdirSync(jobDir, { recursive: true });
const briefPath = join(jobDir, 'brief.md');
const reportJsonPath = join(jobDir, 'report.json');
const reportMdPath = join(jobDir, 'report.md');
rmSync(reportJsonPath, { force: true });
rmSync(reportMdPath, { force: true });
writeFileSync(
briefPath,
buildReviewBrief({
pr: detail,
ci,
mergeBase: wt.mergeBase,
worktreeDir: wt.dir,
mainCheckout: this.cfg.mainCheckout,
reportJsonPath,
reportMdPath,
})
);
Object.assign(rec, { briefPath, reportJsonPath, reportMdPath });
sessionId = await this.codeman.createInteractiveSession({
workingDir: wt.dir,
name: `prbot-${number}`,
modelOverride: this.cfg.model,
effort: this.cfg.effort,
});
this.createdSessions.add(sessionId);
rec.activeSessionId = sessionId;
this.store.save();
log(`session ${sessionId.slice(0, 8)} spawned in ${wt.dir}`);
await this.codeman.ensureReady(sessionId, log);
const isDone = () => existsSync(reportJsonPath) && existsSync(reportMdPath);
const outcome = await this.codeman.runTurn(sessionId, reviewKickoffLine(briefPath), {
deadlineMs: this.cfg.reviewTimeoutMs,
isDone,
log,
});
log(`turn ended: ${outcome.kind}`);
if (outcome.kind === 'stop' && isDone()) {
// Let the session finish its closing line before we read and delete.
await this.codeman.waitSignal(sessionId, 'stop,exit', 15_000).catch(() => undefined);
}
let report: ReviewReport | null = null;
let last = '';
if (existsSync(reportJsonPath)) report = parseReport(extractJsonObject(readFileSync(reportJsonPath, 'utf8')));
if (!report) {
last = await this.pollLastResponse(sessionId);
report = parseReport(extractJsonObject(last));
if (report && !existsSync(reportMdPath)) writeFileSync(reportMdPath, last);
}
await this.recordClaudeSessionId(sessionId, rec);
if (!report) throw new Error(await this.describeFailure(sessionId, outcome, started, last));
const durationMin = Math.max(1, Math.round((Date.now() - started) / 60_000));
Object.assign(rec, {
status: 'reviewed',
failedAttempts: 0,
failedSha: undefined,
reviewedSha: detail.headSha,
reviewedAt: new Date().toISOString(),
reviewDurationMin: durationMin,
verdict: report.verdict,
report,
});
Object.assign(rec, {
additions: detail.additions,
deletions: detail.deletions,
changedFiles: detail.changedFiles,
});
await this.sendSummary(rec);
log(`reviewed: ${report.verdict} (${durationMin} min)`);
} catch (err) {
const reason = errText(err);
log(`review failed: ${reason}`);
if (rec) {
rec.status = 'failed';
rec.lastError = reason;
rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1;
rec.failedSha = rec.headSha;
const givingUp = rec.failedAttempts >= MAX_AUTO_RETRIES;
await this.telegram
.sendMessage(
formatReviewFailure(rec, reason) +
(givingUp
? `\n\nThat was attempt ${rec.failedAttempts}; not retrying this head on my own.`
: ' (retrying on the next scan)')
)
.catch((e) => log(`failure notice: ${errText(e)}`));
}
} finally {
if (progressMsg !== undefined) await this.telegram.deleteMessage(progressMsg);
if (sessionId) await this.releaseSession(sessionId, log);
if (rec) rec.activeSessionId = undefined;
this.store.save();
}
if (!rec) throw new Error(`PR #${number} not found`);
return rec;
}
private async releaseSession(sessionId: string, log: (m: string) => void): Promise {
await this.codeman.deleteSession(sessionId).catch((err) => log(`delete session: ${errText(err)}`));
this.createdSessions.delete(sessionId);
}
private async recordClaudeSessionId(sessionId: string, rec: PrRecord): Promise {
try {
const s = await this.codeman.getSession(sessionId);
if (s.claudeSessionId) rec.claudeSessionId = s.claudeSessionId;
} catch {
// The session may already be gone; the follow-up path copes without an id.
}
}
/** The transcript write lags the stop signal; poll briefly like the skill's `last_text`. */
private async pollLastResponse(sessionId: string): Promise {
let text = '';
for (let i = 0; i < 12; i++) {
text = await this.codeman.lastResponse(sessionId).catch(() => '');
if (text.trim()) return text;
await sleep(1000);
}
return text;
}
private async describeFailure(
sessionId: string,
outcome: TurnOutcome,
started: number,
lastText = ''
): Promise {
const minutes = Math.round((Date.now() - started) / 60_000);
const said = lastText.trim() ? `\nIts last message:\n${lastText.trim().slice(-900)}` : '';
switch (outcome.kind) {
case 'blocked': {
const screen = stripAnsi(await this.codeman.terminalText(sessionId).catch(() => ''));
const tail = screen.trim().split('\n').slice(-25).join('\n').slice(-1200);
return `the reviewer stopped on a question or permission prompt after ${minutes} min:\n${tail}`;
}
case 'exit':
return 'the session exited before writing a report';
case 'timeout':
return `timed out after ${minutes} min without a report`;
default:
return `the session finished after ${minutes} min without writing report.json${said}`;
}
}
// ---- follow-ups ----------------------------------------------------------
private startFollowup(number: number, instruction: string, replyTo?: number): void {
const rec = this.store.pr(number);
if (!rec || !rec.reviewedSha) {
void this.telegram.sendMessage(`No review of #${number} yet. /review ${number} first.`);
return;
}
if (this.busy.has(number)) {
void this.telegram.sendMessage(`#${number} has a session running right now; ask again in a few minutes.`);
return;
}
if (this.followupsRunning >= MAX_FOLLOWUPS) {
void this.telegram.sendMessage(`Two follow-ups are already running; try again shortly.`);
return;
}
this.busy.add(number);
this.followupsRunning++;
void this.followup(rec, instruction, replyTo).finally(() => {
this.busy.delete(number);
this.followupsRunning--;
});
}
private async followup(rec: PrRecord, instruction: string, replyTo?: number): Promise {
const number = rec.number;
const log = (m: string) => this.log(`[#${number} ask] ${m}`);
let sessionId: string | undefined;
try {
const wt = await preparePrWorktree({
mainCheckout: this.cfg.mainCheckout,
worktreesDir: this.cfg.worktreesDir,
prNumber: number,
reset: false,
log,
});
const jobDir = join(this.cfg.dataDir, 'jobs', `pr-${number}`);
mkdirSync(jobDir, { recursive: true });
const followupPath = join(jobDir, `followup-${Date.now()}.md`);
writeFileSync(
followupPath,
buildFollowupBrief({
prNumber: number,
title: rec.title,
instruction,
worktreeDir: wt.dir,
reportMdPath: rec.reportMdPath ?? join(jobDir, 'report.md'),
briefPath: rec.briefPath ?? join(jobDir, 'brief.md'),
})
);
const base = {
workingDir: wt.dir,
name: `prbot-${number}-ask`,
modelOverride: this.cfg.model,
effort: this.cfg.effort,
};
if (rec.claudeSessionId) {
try {
sessionId = await this.codeman.createInteractiveSession({ ...base, resumeSessionId: rec.claudeSessionId });
} catch (err) {
log(`resume of ${rec.claudeSessionId.slice(0, 8)} refused (${errText(err)}); starting fresh`);
}
}
if (!sessionId) sessionId = await this.codeman.createInteractiveSession(base);
this.createdSessions.add(sessionId);
rec.activeSessionId = sessionId;
this.store.save();
await this.codeman.ensureReady(sessionId, log);
const outcome = await this.codeman.runTurn(sessionId, followupKickoffLine(followupPath), {
deadlineMs: this.cfg.followupTimeoutMs,
log,
});
let answer = (await this.pollLastResponse(sessionId)).trim();
if (!answer) answer = await this.describeFailure(sessionId, outcome, Date.now());
await this.recordClaudeSessionId(sessionId, rec);
const moved =
wt.headSha !== rec.reviewedSha
? `⚠️ #${number} has new commits since the review (use /review ${number}).\n\n`
: '';
for (const chunk of splitTelegramMessage(`💬 #${number}\n${moved}${answer}`)) {
const id = await this.telegram.sendPlain(chunk, { replyToMessageId: replyTo });
this.store.rememberMessage(id, number);
}
} catch (err) {
await this.telegram
.sendMessage(`⚠️ Follow-up on #${number} failed: ${escapeHtml(errText(err))}`)
.catch(() => undefined);
} finally {
if (sessionId) await this.releaseSession(sessionId, log);
rec.activeSessionId = undefined;
this.store.save();
}
}
// ---- telegram ------------------------------------------------------------
private async telegramLoop(): Promise {
let backoff = 5000;
while (!this.stopped) {
try {
const updates = await this.telegram.getUpdates(this.store.state.telegramOffset, 50);
backoff = 5000;
for (const update of updates) {
this.store.state.telegramOffset = update.update_id + 1;
this.store.save();
try {
await this.handleUpdate(update);
} catch (err) {
this.log(`update ${update.update_id}: ${errText(err)}`);
}
}
} catch (err) {
if (this.stopped) return;
this.log(`telegram poll: ${errText(err)}; retrying in ${backoff / 1000}s`);
await sleep(backoff);
backoff = Math.min(backoff * 2, 120_000);
}
}
}
async handleUpdate(update: TelegramUpdate): Promise {
if (update.callback_query) return this.handleCallback(update.callback_query);
if (update.message) return this.handleMessage(update.message);
}
private async handleMessage(msg: TelegramMessage): Promise {
if (!this.telegram.isOurChat(msg.chat.id)) return;
const cmd = parseCommand(msg.text);
if (!cmd) {
const replyId = msg.reply_to_message?.message_id;
const text = msg.text?.trim();
if (!text) return;
if (replyId !== undefined) {
const reasonPr = this.store.state.reasonPrompts[String(replyId)];
if (reasonPr !== undefined) {
delete this.store.state.reasonPrompts[String(replyId)];
this.store.save();
return this.startConfirm('close', reasonPr, text);
}
const pr = this.store.prForMessage(replyId) ?? prNumberFromMessageText(msg.reply_to_message?.text) ?? undefined;
if (pr !== undefined) return this.startFollowup(pr, text, msg.message_id);
}
await this.telegram.sendMessage('Reply to a review message to ask about that PR, or see /help.');
return;
}
const need = (): number | null => {
if (cmd.prNumber === undefined) {
void this.telegram.sendMessage(`Which PR? /${cmd.command} 123`);
return null;
}
return cmd.prNumber;
};
switch (cmd.command) {
case 'start':
case 'help':
await this.telegram.sendMessage(HELP);
return;
case 'status':
await this.sendStatus();
return;
case 'scan': {
const r = await this.scanOnce('command');
await this.telegram.sendMessage(
`Scanned: ${r.queued.length ? `queued ${r.queued.map((n) => `#${n}`).join(', ')}` : 'nothing new'}${
r.closed.length ? `; closed ${r.closed.map((n) => `#${n}`).join(', ')}` : ''
}.`
);
return;
}
case 'review':
case 'rescan': {
const n = need();
if (n === null) return;
await this.queueByCommand(n);
return;
}
case 'report': {
const n = need();
if (n !== null) await this.sendReport(n);
return;
}
case 'summary': {
const n = need();
if (n === null) return;
const rec = this.store.pr(n);
if (!rec?.report) await this.telegram.sendMessage(`No review of #${n} yet.`);
else await this.sendSummary(rec);
return;
}
case 'draft': {
const n = need();
if (n !== null) await this.sendDraft(n);
return;
}
case 'post': {
const n = need();
if (n !== null) await this.startConfirm('post', n);
return;
}
case 'merge': {
const n = need();
if (n !== null) await this.startConfirm('merge', n);
return;
}
case 'close': {
const n = need();
if (n === null) return;
if (cmd.rest) await this.startConfirm('close', n, cmd.rest);
else await this.askCloseReason(n);
return;
}
case 'approve':
case 'approveci': {
const n = need();
if (n !== null) await this.approveCi(n);
return;
}
case 'ask': {
const n = need();
if (n === null) return;
if (!cmd.rest) {
await this.telegram.sendMessage(`Ask what? /ask ${n} does this handle X?`);
return;
}
this.startFollowup(n, cmd.rest, msg.message_id);
return;
}
case 'pause':
this.store.state.paused = true;
this.store.save();
await this.telegram.sendMessage('⏸ Auto-review paused. /review N still works; /resume to continue.');
return;
case 'resume': {
this.store.state.paused = false;
this.store.save();
const r = await this.scanOnce('resume');
await this.telegram.sendMessage(
`▶️ Auto-review resumed${r.queued.length ? `; queued ${r.queued.map((n) => `#${n}`).join(', ')}` : ''}.`
);
return;
}
default:
await this.telegram.sendMessage(`Unknown command /${escapeHtml(cmd.command)}. See /help.`);
}
}
private async handleCallback(cb: TelegramCallbackQuery): Promise {
const ack = (text?: string) => this.telegram.answerCallback(cb.id, text).catch(() => undefined);
if (!cb.message || !this.telegram.isOurChat(cb.message.chat.id)) {
await ack();
return;
}
const parsed = parseCallback(cb.data);
if (!parsed) {
await ack();
return;
}
const n = parsed.prNumber;
switch (parsed.action) {
case 'report':
await ack('Sending the report…');
await this.sendReport(n);
return;
case 'draft':
await ack();
await this.sendDraft(n);
return;
case 'review': {
await ack();
await this.queueByCommand(n);
return;
}
case 'merge':
await ack();
await this.startConfirm('merge', n);
return;
case 'post':
await ack();
await this.startConfirm('post', n);
return;
case 'close':
await ack();
await this.askCloseReason(n);
return;
case 'approveci':
await ack();
await this.approveCi(n);
return;
case 'confirm':
await ack();
await this.runConfirmed(parsed.target ?? '', n, parsed.nonce ?? '', cb.message.message_id);
return;
case 'cancel':
delete this.store.state.pending[parsed.nonce ?? ''];
this.store.save();
await this.telegram.editReplyMarkup(cb.message.message_id, { inline_keyboard: [] });
await ack('Cancelled');
return;
default:
await ack();
}
}
private async queueByCommand(n: number): Promise {
let rec = this.store.pr(n);
if (!rec) {
try {
rec = this.store.upsertPr(await getPrDetail(this.cfg.githubRepo, n));
this.store.save();
} catch (err) {
await this.telegram.sendMessage(`Could not load #${n}: ${escapeHtml(errText(err))}`);
return;
}
}
rec.failedAttempts = 0;
const result = this.enqueueReview(n, { front: true });
if (result === 'busy') await this.telegram.sendMessage(`#${n} is being reviewed right now.`);
else {
const ahead = this.reviewing !== null ? ` after #${this.reviewing} finishes` : '';
await this.telegram.sendMessage(`Queued #${n} for review${ahead}.`);
}
}
private async sendStatus(): Promise {
const rows = this.store.openPrs().map((r) => ({
number: r.number,
title: r.title,
author: r.author,
verdict: r.verdict,
status: r.status,
ci: r.ci,
mergeable: r.mergeable,
isDraft: r.isDraft,
}));
let text = formatStatusList(rows, this.store.state.paused);
const live = [
this.reviewing !== null ? `reviewing #${this.reviewing}` : '',
this.reviewQueue.length ? `queue: ${this.reviewQueue.map((n) => `#${n}`).join(', ')}` : '',
]
.filter(Boolean)
.join(' · ');
if (live) text += `\n\n${live}`;
for (const chunk of splitTelegramMessage(text)) await this.telegram.sendMessage(chunk);
}
/** The report message with its buttons; also behind /summary N to bring the buttons back. */
async sendSummary(rec: PrRecord): Promise {
if (!rec.report) return;
const summaryPr: PrSummary = {
number: rec.number,
title: rec.title,
author: rec.author,
headSha: rec.reviewedSha ?? rec.headSha,
baseRef: 'master',
headRef: '',
isDraft: rec.isDraft,
mergeable: rec.mergeable,
mergeState: '',
additions: rec.additions ?? 0,
deletions: rec.deletions ?? 0,
changedFiles: rec.changedFiles ?? 0,
updatedAt: rec.updatedAt,
url: rec.url,
isCrossRepository: true,
labels: [],
};
const ci = rec.ci ?? 'none';
const text = formatTelegramSummary(summaryPr, rec.report, { ci, durationMin: rec.reviewDurationMin });
const keyboard = buildReportKeyboard(rec.number, { ci, hasDraft: Boolean(rec.report.draftComment) });
const msgId = await this.telegram.sendMessage(text, { replyMarkup: { inline_keyboard: keyboard } });
rec.telegramMessageId = msgId;
this.store.rememberMessage(msgId, rec.number);
this.store.save();
}
private async sendReport(n: number): Promise {
const rec = this.store.pr(n);
if (!rec?.reportMdPath || !existsSync(rec.reportMdPath)) {
await this.telegram.sendMessage(`No report for #${n} yet.`);
return;
}
const content = readFileSync(rec.reportMdPath, 'utf8');
if (content.length <= REPORT_INLINE_MAX) {
const id = await this.telegram.sendPlain(`📄 Review of #${n}\n\n${content}`);
this.store.rememberMessage(id, n);
this.store.save();
return;
}
await this.telegram.sendDocument(`pr-${n}-review.md`, content, `📄 Review of #${n} · ${rec.title}`.slice(0, 1000));
}
private async sendDraft(n: number): Promise {
const rec = this.store.pr(n);
const draft = rec?.report?.draftComment;
if (!draft) {
await this.telegram.sendMessage(`No draft comment for #${n}.`);
return;
}
for (const chunk of splitTelegramMessage(
`💬 Draft comment for #${n} (not posted; /post ${n} to post it):\n\n${draft}`
)) {
const id = await this.telegram.sendPlain(chunk);
this.store.rememberMessage(id, n);
}
this.store.save();
}
private async askCloseReason(n: number): Promise {
const id = await this.telegram.sendMessage(
`Reply to this message with the closing comment for #${n} (it is posted on the PR when you confirm), or use /close ${n} reason.`
);
this.store.state.reasonPrompts[String(id)] = n;
this.store.save();
}
private async approveCi(n: number): Promise {
const rec = this.store.pr(n);
if (!rec) {
await this.telegram.sendMessage(`Unknown PR #${n}.`);
return;
}
try {
const ci = await getCiStatus(this.cfg.githubRepo, rec.headSha);
const waiting = ci.runs.filter((r) => r.conclusion === 'action_required');
if (!waiting.length) {
await this.telegram.sendMessage(`Nothing to approve for #${n} (CI: ${ci.state}).`);
return;
}
for (const run of waiting) await approveWorkflowRun(this.cfg.githubRepo, run.id);
rec.ci = 'pending';
this.store.save();
await this.telegram.sendMessage(
`▶️ Approved ${waiting.length} workflow run${waiting.length === 1 ? '' : 's'} for #${n}; CI is starting.`
);
} catch (err) {
await this.telegram.sendMessage(`⚠️ Approving CI for #${n} failed: ${escapeHtml(errText(err))}`);
}
}
// ---- confirmations for GitHub writes --------------------------------------
private async startConfirm(action: PendingConfirm['action'], n: number, reason?: string): Promise {
const rec = this.store.pr(n);
if (!rec) {
await this.telegram.sendMessage(`Unknown PR #${n}.`);
return;
}
let text: string;
if (action === 'post') {
const draft = rec.report?.draftComment;
if (!draft) {
await this.telegram.sendMessage(`No draft comment for #${n}.`);
return;
}
for (const chunk of splitTelegramMessage(draft)) await this.telegram.sendPlain(chunk);
text = `📮 Post the comment above on #${n} · ${escapeHtml(rec.title)}? It goes out under your GitHub account.`;
} else if (action === 'close') {
if (!reason?.trim()) {
await this.askCloseReason(n);
return;
}
text = `🗑 Close #${n} · ${escapeHtml(rec.title)} (${escapeHtml(rec.author)}) with this comment?\n\n${escapeHtml(reason.trim())}`;
} else {
let fresh: PrSummary | undefined;
let ci: CiStatus | undefined;
try {
fresh = await getPrDetail(this.cfg.githubRepo, n);
ci = await getCiStatus(this.cfg.githubRepo, fresh.headSha);
} catch (err) {
await this.telegram.sendMessage(`Could not check #${n} before merging: ${escapeHtml(errText(err))}`);
return;
}
if (fresh.mergeable === 'CONFLICTING') {
await this.telegram.sendMessage(`#${n} conflicts with master; it needs a rebase before it can be merged.`);
return;
}
const notes: string[] = [];
if (ci.state === 'failed') notes.push('⚠️ CI is red');
if (ci.state === 'awaiting-approval') notes.push('⚠️ CI never ran (waiting for your approval)');
if (ci.state === 'pending') notes.push('⏳ CI still running');
if (ci.state === 'none') notes.push('⚠️ no CI runs for this head');
if (rec.reviewedSha && rec.reviewedSha !== fresh.headSha) notes.push('⚠️ new commits since the review');
if (fresh.isDraft) notes.push('⚠️ still a draft');
if (rec.verdict && rec.verdict !== 'merge' && rec.verdict !== 'merge-with-fixes')
notes.push(`⚠️ the review said ${rec.verdict.replace(/-/g, ' ')}`);
text =
`✅ Merge #${n} · ${escapeHtml(fresh.title)} (${escapeHtml(fresh.author)}) into ${escapeHtml(fresh.baseRef)} with a merge commit?` +
`\n${fresh.mergeable === 'MERGEABLE' ? 'mergeable' : 'mergeability unknown'} · CI ${ci.state} · head ${fresh.headSha.slice(0, 8)}` +
(notes.length ? `\n${notes.join('\n')}` : '');
}
const nonce = randomBytes(4).toString('hex');
const pending: PendingConfirm = {
action,
prNumber: n,
createdAt: new Date().toISOString(),
reason: reason?.trim(),
};
const id = await this.telegram.sendMessage(text, {
replyMarkup: { inline_keyboard: confirmKeyboard(action, n, nonce) },
});
pending.messageId = id;
this.store.state.pending[nonce] = pending;
this.store.rememberMessage(id, n);
this.store.save();
}
private async runConfirmed(target: string, n: number, nonce: string, messageId: number): Promise {
const pending = this.store.state.pending[nonce];
delete this.store.state.pending[nonce];
this.store.save();
const fresh = pending && pending.prNumber === n && pending.action === target;
const expired = !pending || Date.now() - Date.parse(pending.createdAt) > CONFIRM_TTL_MS;
await this.telegram.editReplyMarkup(messageId, { inline_keyboard: [] });
if (!fresh || expired) {
await this.telegram.sendMessage(`That confirmation is no longer valid; run the command again.`);
return;
}
const rec = this.store.pr(n);
try {
switch (pending.action) {
case 'merge': {
await mergePr(this.cfg.githubRepo, n);
this.selfClosed.add(n);
const fixes =
rec?.verdict === 'merge-with-fixes'
? ' The review listed fixes to apply at merge time; they are not applied by merging (see /report).'
: '';
await this.telegram.sendMessage(`🎉 Merged #${n}${rec ? ` · ${escapeHtml(rec.title)}` : ''}.${fixes}`);
this.scheduleScan(5000);
return;
}
case 'close': {
await closePr(this.cfg.githubRepo, n, pending.reason ?? '');
this.selfClosed.add(n);
await this.telegram.sendMessage(`🔒 Closed #${n}${rec ? ` · ${escapeHtml(rec.title)}` : ''}.`);
this.scheduleScan(5000);
return;
}
case 'post': {
const draft = rec?.report?.draftComment;
if (!draft) throw new Error('the draft comment is gone');
await commentPr(this.cfg.githubRepo, n, draft);
await this.telegram.sendMessage(`📮 Posted the review comment on #${n}.`);
return;
}
}
} catch (err) {
await this.telegram.sendMessage(`⚠️ ${pending.action} on #${n} failed: ${escapeHtml(errText(err))}`);
}
}
}