mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +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,268 @@
|
||||
/**
|
||||
* @fileoverview Codeman HTTP client for the PR bot: spawn a claude session in a
|
||||
* directory, wait until its composer is up, run one prompt to the END of its turn,
|
||||
* read the answer, delete the session.
|
||||
*
|
||||
* This is the `skills/codeman` §0 preamble translated to TypeScript, and it keeps
|
||||
* the traps that preamble documents:
|
||||
* - readiness is the rendered composer (`shift+tab` in the pane), never `idle`;
|
||||
* - the folder-trust dialog is READ off the screen and answered one keystroke at a
|
||||
* time (Claude Code 2.1.252 highlights "No, exit" by default, so a blind Enter kills
|
||||
* the session);
|
||||
* - send-and-wait waits on `stop,blocked,exit`, never on the flapping `idle`, with a
|
||||
* short first wait, one Enter nudge for a stranded prompt, and tagged-duplicate
|
||||
* resends that re-wait without retyping (the server treats an already-applied
|
||||
* (clientId, seq) frame as "wait only");
|
||||
* - the bot deletes only sessions it created, by exact id.
|
||||
*
|
||||
* The production server is HTTPS with a self-signed certificate on loopback, so the
|
||||
* undici Agent skips certificate verification for that one connection.
|
||||
*/
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
|
||||
export interface CodemanClientOptions {
|
||||
apiUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
workingDir: string;
|
||||
name: string;
|
||||
modelOverride?: string;
|
||||
effort?: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
export interface WaitResult {
|
||||
ended: boolean;
|
||||
timedOut: boolean;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
claudeSessionId?: string | null;
|
||||
workingDir: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export type TurnOutcome = { kind: 'stop' } | { kind: 'blocked' } | { kind: 'exit' } | { kind: 'timeout' };
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/** Which key answers the trust dialog right now, read from the rendered pane. */
|
||||
export function trustDialogKey(screen: string): 'confirm' | 'move' | null {
|
||||
const compact = stripAnsi(screen).replace(/\s+/g, '');
|
||||
const matches = compact.match(/❯[0-9.]*(yes,itrustthisfolder|no,exit)/gi);
|
||||
if (!matches || matches.length === 0) return null;
|
||||
const last = matches[matches.length - 1].toLowerCase();
|
||||
return last.includes('yes,') ? 'confirm' : 'move';
|
||||
}
|
||||
|
||||
export class CodemanClient {
|
||||
// headersTimeout/bodyTimeout default to 300 s in undici, which is shorter than one
|
||||
// long-poll slice on the wait endpoints (up to 580 s): the first review died at
|
||||
// exactly five minutes with a bare "fetch failed". The per-request AbortSignal is
|
||||
// the only ceiling here.
|
||||
private readonly agent = new Agent({ connect: { rejectUnauthorized: false }, headersTimeout: 0, bodyTimeout: 0 });
|
||||
private readonly authHeader?: string;
|
||||
|
||||
constructor(private readonly opts: CodemanClientOptions) {
|
||||
if (opts.password) {
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${opts.username || 'admin'}:${opts.password}`).toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
timeoutMs = 60_000
|
||||
): Promise<T> {
|
||||
const url = new URL(this.opts.apiUrl + path);
|
||||
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v));
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.authHeader) headers.Authorization = this.authHeader;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
let res;
|
||||
try {
|
||||
res = await undiciFetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
dispatcher: this.agent,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const cause = (err as { cause?: { message?: string; code?: string } }).cause;
|
||||
const detail = cause ? ` (${cause.code ?? ''} ${cause.message ?? ''})`.replace(/\(\s+/, '(').trim() : '';
|
||||
throw new Error(`${method} ${path}: ${(err as Error).message}${detail}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
let json: { success?: boolean; data?: T; error?: string; errorCode?: string } & Record<string, unknown> = {};
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: non-JSON ${res.status} response: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!res.ok || json.success === false) {
|
||||
throw new Error(
|
||||
`${method} ${path}: ${res.status} ${json.errorCode ?? ''} ${json.error ?? text.slice(0, 200)}`.trim()
|
||||
);
|
||||
}
|
||||
// Most routes use the {success, data} envelope; a few legacy GETs return the raw shape.
|
||||
return (json.success === true && json.data !== undefined ? json.data : json) as T;
|
||||
}
|
||||
|
||||
async status(): Promise<{ version?: string }> {
|
||||
return this.request<{ version?: string }>('GET', '/api/status');
|
||||
}
|
||||
|
||||
async listSessions(): Promise<SessionRecord[]> {
|
||||
const data = await this.request<SessionRecord[] | { sessions: SessionRecord[] }>('GET', '/api/sessions');
|
||||
return Array.isArray(data) ? data : (data.sessions ?? []);
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<SessionRecord> {
|
||||
return this.request<SessionRecord>('GET', `/api/sessions/${id}`);
|
||||
}
|
||||
|
||||
/** Create + start. Creation alone leaves pid null and no pane, so the two are one step here. */
|
||||
async createInteractiveSession(opts: CreateSessionOptions): Promise<string> {
|
||||
const created = await this.request<{ session: { id: string } }>('POST', '/api/sessions', {
|
||||
workingDir: opts.workingDir,
|
||||
mode: 'claude',
|
||||
name: opts.name,
|
||||
modelOverride: opts.modelOverride,
|
||||
effort: opts.effort,
|
||||
resumeSessionId: opts.resumeSessionId,
|
||||
});
|
||||
const id = created.session?.id;
|
||||
if (!id) throw new Error('POST /api/sessions returned no session id');
|
||||
await this.request('POST', `/api/sessions/${id}/interactive`, {});
|
||||
return id;
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
if (!id || id.length < 8) throw new Error(`refusing to delete session "${id}"`);
|
||||
await this.request('DELETE', `/api/sessions/${id}`);
|
||||
}
|
||||
|
||||
async waitOutput(id: string, match: string, from: 'now' | 'buffer', timeoutMs: number): Promise<boolean> {
|
||||
const data = await this.request<{ wait?: { matched?: boolean } }>(
|
||||
'GET',
|
||||
`/api/sessions/${id}/wait-output`,
|
||||
undefined,
|
||||
{ match, from, timeout: timeoutMs },
|
||||
timeoutMs + 15_000
|
||||
);
|
||||
return Boolean(data.wait?.matched);
|
||||
}
|
||||
|
||||
async waitSignal(id: string, until: string, timeoutMs: number): Promise<WaitResult> {
|
||||
const data = await this.request<{ wait?: WaitResult }>(
|
||||
'GET',
|
||||
`/api/sessions/${id}/wait`,
|
||||
undefined,
|
||||
{ until, timeout: timeoutMs },
|
||||
timeoutMs + 15_000
|
||||
);
|
||||
return data.wait ?? { ended: false, timedOut: true };
|
||||
}
|
||||
|
||||
async terminalText(id: string): Promise<string> {
|
||||
const data = await this.request<{ terminalBuffer?: string }>('GET', `/api/sessions/${id}/terminal`, undefined, {
|
||||
full: '1',
|
||||
});
|
||||
return data.terminalBuffer ?? '';
|
||||
}
|
||||
|
||||
async sendKeys(id: string, input: string, clientId: string, seq: number): Promise<void> {
|
||||
await this.request('POST', `/api/sessions/${id}/input`, { input, useMux: true, clientId, seq });
|
||||
}
|
||||
|
||||
async lastResponse(id: string): Promise<string> {
|
||||
const data = await this.request<{ text?: string }>('GET', `/api/sessions/${id}/last-response`);
|
||||
return data.text ?? '';
|
||||
}
|
||||
|
||||
/** Composer wait, trust-dialog fallback, composer wait again. Throws when the pane never gets there. */
|
||||
async ensureReady(id: string, log: (m: string) => void): Promise<void> {
|
||||
if (await this.waitOutput(id, 'shift+tab', 'buffer', 5000)) return;
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const key = trustDialogKey(await this.terminalText(id));
|
||||
if (!key) break;
|
||||
log(`trust dialog on screen: ${key === 'confirm' ? 'Enter' : 'arrow down'}`);
|
||||
await this.sendKeys(id, key === 'confirm' ? '\r' : '\x1b[B', `prbot-trust-${id}`, i);
|
||||
if (key === 'confirm') break;
|
||||
await sleep(1000);
|
||||
}
|
||||
if (await this.waitOutput(id, 'shift+tab', 'buffer', 45_000)) return;
|
||||
throw new Error('the session never drew its composer (no `shift+tab` in the pane after 50s)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ONE prompt and block until the turn ends, the session blocks on a question,
|
||||
* the pane exits, or `deadlineMs` passes. `isDone` lets the caller finish early on
|
||||
* an out-of-band signal (the report file appearing), which also covers a stop edge
|
||||
* that fired between two waits.
|
||||
*/
|
||||
async runTurn(
|
||||
id: string,
|
||||
prompt: string,
|
||||
opts: { deadlineMs: number; isDone?: () => boolean; log: (m: string) => void }
|
||||
): Promise<TurnOutcome> {
|
||||
if (prompt.includes('\n'))
|
||||
throw new Error('runTurn prompts must be single-line (embedded newlines are stripped by tmux)');
|
||||
const clientId = `prbot-${id}`;
|
||||
const seq = Math.floor(Date.now() / 1000);
|
||||
const frame = { input: prompt + '\r', useMux: true, clientId, seq, wait: 'stop,blocked,exit', waitTimeout: 20_000 };
|
||||
const started = Date.now();
|
||||
const post = (body: unknown, timeout: number) =>
|
||||
this.request<{ delivered?: boolean; wait?: WaitResult }>(
|
||||
'POST',
|
||||
`/api/sessions/${id}/input`,
|
||||
body,
|
||||
undefined,
|
||||
timeout + 15_000
|
||||
);
|
||||
|
||||
let r = await post(frame, 20_000);
|
||||
if (!r.delivered) throw new Error('the prompt was not delivered (pane dead?)');
|
||||
let wait = r.wait;
|
||||
let nudged = false;
|
||||
while (true) {
|
||||
if (wait && !wait.timedOut) return toOutcome(wait);
|
||||
if (opts.isDone?.()) return { kind: 'stop' };
|
||||
const remaining = opts.deadlineMs - (Date.now() - started);
|
||||
if (remaining <= 0) return { kind: 'timeout' };
|
||||
if (!nudged) {
|
||||
// An Ink repaint occasionally eats the Enter: a bare \r is the missing key when
|
||||
// the prompt is stranded and a no-op when the turn is genuinely running.
|
||||
nudged = true;
|
||||
await this.sendKeys(id, '\r', clientId, seq + 1);
|
||||
}
|
||||
const slice = Math.min(remaining, 580_000);
|
||||
opts.log(`still working (${Math.round((Date.now() - started) / 60_000)} min)`);
|
||||
r = await post({ ...frame, waitTimeout: slice }, slice);
|
||||
wait = r.wait;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toOutcome(wait: WaitResult): TurnOutcome {
|
||||
const signal = wait.signal ?? '';
|
||||
if (signal === 'blocked') return { kind: 'blocked' };
|
||||
if (signal === 'exit') return { kind: 'exit' };
|
||||
return { kind: 'stop' };
|
||||
}
|
||||
Reference in New Issue
Block a user