mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
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:
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @fileoverview The bot's persisted state: one record per PR (what was reviewed at
|
||||
* which head, the parsed report, the Claude session to resume for follow-ups, the
|
||||
* Telegram messages that belong to it), the Telegram update offset, pending
|
||||
* confirmations, and the pause flag. One JSON file, written atomically (tmp + rename)
|
||||
* with mode 0600, since reports quote code and draft comments.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import type { CiState, PrSummary } from './github.js';
|
||||
import type { ReviewReport } from './report.js';
|
||||
import type { Verdict } from './review-task.js';
|
||||
|
||||
export type PrStatus = 'new' | 'queued' | 'reviewing' | 'reviewed' | 'failed' | 'skipped' | 'closed';
|
||||
|
||||
export interface PrRecord {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
url: string;
|
||||
headSha: string;
|
||||
isDraft: boolean;
|
||||
mergeable: PrSummary['mergeable'];
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changedFiles?: number;
|
||||
status: PrStatus;
|
||||
ci?: CiState;
|
||||
reviewedSha?: string;
|
||||
reviewedAt?: string;
|
||||
reviewDurationMin?: number;
|
||||
verdict?: Verdict;
|
||||
report?: ReviewReport;
|
||||
briefPath?: string;
|
||||
reportJsonPath?: string;
|
||||
reportMdPath?: string;
|
||||
/** The Claude conversation to resume for follow-ups. */
|
||||
claudeSessionId?: string;
|
||||
/** The live Codeman session while a turn is running; cleared afterwards. */
|
||||
activeSessionId?: string;
|
||||
worktreeDir?: string;
|
||||
telegramMessageId?: number;
|
||||
lastError?: string;
|
||||
closedAs?: 'merged' | 'closed';
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PendingConfirm {
|
||||
action: 'merge' | 'close' | 'post';
|
||||
prNumber: number;
|
||||
createdAt: string;
|
||||
messageId?: number;
|
||||
/** Closing comment for `close`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface BotState {
|
||||
version: 1;
|
||||
paused: boolean;
|
||||
telegramOffset: number;
|
||||
prs: Record<string, PrRecord>;
|
||||
pending: Record<string, PendingConfirm>;
|
||||
/** Telegram message id -> PR number, so a reply to any of the bot's messages finds its PR. */
|
||||
messages: Record<string, number>;
|
||||
/** Telegram message id -> PR number for "reply with the closing reason" prompts. */
|
||||
reasonPrompts: Record<string, number>;
|
||||
}
|
||||
|
||||
export function emptyState(): BotState {
|
||||
return { version: 1, paused: false, telegramOffset: 0, prs: {}, pending: {}, messages: {}, reasonPrompts: {} };
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_MAP = 2000;
|
||||
|
||||
export class StateStore {
|
||||
state: BotState;
|
||||
|
||||
constructor(private readonly path: string) {
|
||||
this.state = emptyState();
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<BotState>;
|
||||
this.state = { ...emptyState(), ...parsed, version: 1 };
|
||||
} catch (err) {
|
||||
throw new Error(`state file ${path} is unreadable: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save(): void {
|
||||
mkdirSync(dirname(this.path), { recursive: true });
|
||||
this.pruneMessageMap();
|
||||
const tmp = join(dirname(this.path), `.state.${process.pid}.${Date.now()}.tmp`);
|
||||
writeFileSync(tmp, JSON.stringify(this.state, null, 2), { mode: 0o600 });
|
||||
renameSync(tmp, this.path);
|
||||
}
|
||||
|
||||
pr(number: number): PrRecord | undefined {
|
||||
return this.state.prs[String(number)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a PR's metadata, keeping its review. Mutates the EXISTING record in place:
|
||||
* a review in flight holds a reference to it, and a scan that replaced the object
|
||||
* with a copy made that review write its verdict into an orphan (first daemon run:
|
||||
* PR 363 reported to Telegram, state still said `reviewing`).
|
||||
*/
|
||||
upsertPr(summary: PrSummary): PrRecord {
|
||||
const key = String(summary.number);
|
||||
const existing = this.state.prs[key];
|
||||
const record: PrRecord = existing ?? {
|
||||
number: summary.number,
|
||||
title: summary.title,
|
||||
author: summary.author,
|
||||
url: summary.url,
|
||||
headSha: summary.headSha,
|
||||
isDraft: summary.isDraft,
|
||||
mergeable: summary.mergeable,
|
||||
status: 'new',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
record.title = summary.title;
|
||||
record.author = summary.author;
|
||||
record.url = summary.url;
|
||||
record.headSha = summary.headSha;
|
||||
record.isDraft = summary.isDraft;
|
||||
record.mergeable = summary.mergeable;
|
||||
record.additions = summary.additions;
|
||||
record.deletions = summary.deletions;
|
||||
record.changedFiles = summary.changedFiles;
|
||||
if (record.status === 'closed') {
|
||||
// Reopened.
|
||||
record.status = record.reviewedSha ? 'reviewed' : 'new';
|
||||
record.closedAs = undefined;
|
||||
}
|
||||
record.updatedAt = new Date().toISOString();
|
||||
this.state.prs[key] = record;
|
||||
return record;
|
||||
}
|
||||
|
||||
openPrs(): PrRecord[] {
|
||||
return Object.values(this.state.prs)
|
||||
.filter((r) => r.status !== 'closed')
|
||||
.sort((a, b) => b.number - a.number);
|
||||
}
|
||||
|
||||
rememberMessage(messageId: number, prNumber: number): void {
|
||||
this.state.messages[String(messageId)] = prNumber;
|
||||
}
|
||||
|
||||
prForMessage(messageId: number | undefined): number | undefined {
|
||||
if (messageId === undefined) return undefined;
|
||||
return this.state.messages[String(messageId)];
|
||||
}
|
||||
|
||||
private pruneMessageMap(): void {
|
||||
const keys = Object.keys(this.state.messages);
|
||||
if (keys.length <= MAX_MESSAGE_MAP) return;
|
||||
// Message ids grow monotonically per chat; drop the oldest.
|
||||
keys.sort((a, b) => Number(a) - Number(b));
|
||||
for (const key of keys.slice(0, keys.length - MAX_MESSAGE_MAP)) delete this.state.messages[key];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user