mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
/**
|
||
* @fileoverview Pure report handling: parse the reviewer's JSON (leniently, it is
|
||
* model output), render the Telegram summary (HTML, under the 4096-char cap), the
|
||
* status list, the inline keyboard, and the backlog order. Unit-tested.
|
||
*/
|
||
import type { CiState, PrSummary } from './github.js';
|
||
import { VERDICTS, type Verdict } from './review-task.js';
|
||
|
||
export type Severity = 'blocker' | 'major' | 'minor' | 'nit';
|
||
|
||
export interface Finding {
|
||
severity: Severity;
|
||
title: string;
|
||
file?: string;
|
||
line?: number;
|
||
detail: string;
|
||
invariant?: string;
|
||
}
|
||
|
||
export interface CheckResult {
|
||
name: string;
|
||
command?: string;
|
||
result: 'pass' | 'fail' | 'skipped';
|
||
notes?: string;
|
||
}
|
||
|
||
export interface ReviewReport {
|
||
verdict: Verdict;
|
||
confidence: 'high' | 'medium' | 'low';
|
||
summary: string;
|
||
changes: string[];
|
||
findings: Finding[];
|
||
checks: CheckResult[];
|
||
scope: 'focused' | 'mixed';
|
||
risk: string;
|
||
recommendation: string;
|
||
draftComment: string;
|
||
assumptions: string[];
|
||
}
|
||
|
||
export const TELEGRAM_MAX = 4096;
|
||
/** Leave room for HTML tags the counter cannot see and for the keyboard-less fallback. */
|
||
const SUMMARY_BUDGET = 3600;
|
||
|
||
const SEVERITY_ORDER: Severity[] = ['blocker', 'major', 'minor', 'nit'];
|
||
const SEVERITY_ICON: Record<Severity, string> = { blocker: '🔴', major: '🟠', minor: '🟡', nit: '⚪' };
|
||
const VERDICT_LABEL: Record<Verdict, string> = {
|
||
merge: '✅ MERGE',
|
||
'merge-with-fixes': '🟢 MERGE WITH FIXES',
|
||
'request-changes': '🟠 REQUEST CHANGES',
|
||
close: '❌ CLOSE',
|
||
'needs-discussion': '💬 NEEDS DISCUSSION',
|
||
};
|
||
const CI_LABEL: Record<CiState, string> = {
|
||
passed: 'CI ✅',
|
||
failed: 'CI ❌',
|
||
pending: 'CI ⏳',
|
||
'awaiting-approval': 'CI ⏸ needs your approval',
|
||
none: 'CI none',
|
||
};
|
||
|
||
export function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function str(v: unknown, fallback = ''): string {
|
||
return typeof v === 'string' ? v : fallback;
|
||
}
|
||
|
||
function strList(v: unknown): string[] {
|
||
if (!Array.isArray(v)) return [];
|
||
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0);
|
||
}
|
||
|
||
/** Extract the first JSON object from text that may carry fences or prose around it. */
|
||
export function extractJsonObject(text: string): unknown {
|
||
const trimmed = text.trim();
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
// fall through
|
||
}
|
||
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||
if (fence) {
|
||
try {
|
||
return JSON.parse(fence[1]);
|
||
} catch {
|
||
// fall through
|
||
}
|
||
}
|
||
const start = trimmed.indexOf('{');
|
||
const end = trimmed.lastIndexOf('}');
|
||
if (start >= 0 && end > start) {
|
||
try {
|
||
return JSON.parse(trimmed.slice(start, end + 1));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Normalize model output into a ReviewReport. Returns null only when there is no verdict at all. */
|
||
export function parseReport(raw: unknown): ReviewReport | null {
|
||
if (!raw || typeof raw !== 'object') return null;
|
||
const o = raw as Record<string, unknown>;
|
||
const verdictRaw = str(o.verdict).trim().toLowerCase().replace(/[_ ]/g, '-');
|
||
const verdict = (VERDICTS as readonly string[]).includes(verdictRaw) ? (verdictRaw as Verdict) : null;
|
||
if (!verdict) return null;
|
||
const confidenceRaw = str(o.confidence).trim().toLowerCase();
|
||
const confidence = confidenceRaw === 'high' || confidenceRaw === 'low' ? confidenceRaw : 'medium';
|
||
|
||
const findings: Finding[] = [];
|
||
if (Array.isArray(o.findings)) {
|
||
for (const f of o.findings) {
|
||
if (!f || typeof f !== 'object') continue;
|
||
const fo = f as Record<string, unknown>;
|
||
const sevRaw = str(fo.severity).trim().toLowerCase();
|
||
const severity = (SEVERITY_ORDER as string[]).includes(sevRaw) ? (sevRaw as Severity) : 'minor';
|
||
const title = str(fo.title).trim();
|
||
if (!title) continue;
|
||
const line = typeof fo.line === 'number' && Number.isFinite(fo.line) ? Math.trunc(fo.line) : undefined;
|
||
findings.push({
|
||
severity,
|
||
title,
|
||
file: str(fo.file).trim() || undefined,
|
||
line,
|
||
detail: str(fo.detail).trim(),
|
||
invariant: str(fo.invariant).trim() || undefined,
|
||
});
|
||
}
|
||
}
|
||
findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
|
||
|
||
const checks: CheckResult[] = [];
|
||
if (Array.isArray(o.checks)) {
|
||
for (const c of o.checks) {
|
||
if (!c || typeof c !== 'object') continue;
|
||
const co = c as Record<string, unknown>;
|
||
const name = str(co.name).trim();
|
||
if (!name) continue;
|
||
const resRaw = str(co.result).trim().toLowerCase();
|
||
const result = resRaw === 'pass' || resRaw === 'fail' ? resRaw : 'skipped';
|
||
checks.push({
|
||
name,
|
||
command: str(co.command).trim() || undefined,
|
||
result,
|
||
notes: str(co.notes).trim() || undefined,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict,
|
||
confidence,
|
||
summary: str(o.summary).trim(),
|
||
changes: strList(o.changes),
|
||
findings,
|
||
checks,
|
||
scope: str(o.scope).trim().toLowerCase() === 'mixed' ? 'mixed' : 'focused',
|
||
risk: str(o.risk).trim(),
|
||
recommendation: str(o.recommendation).trim(),
|
||
draftComment: str(o.draftComment).trim(),
|
||
assumptions: strList(o.assumptions),
|
||
};
|
||
}
|
||
|
||
export function countBySeverity(findings: Finding[]): Record<Severity, number> {
|
||
const out: Record<Severity, number> = { blocker: 0, major: 0, minor: 0, nit: 0 };
|
||
for (const f of findings) out[f.severity]++;
|
||
return out;
|
||
}
|
||
|
||
function findingLine(f: Finding): string {
|
||
const where = f.file ? ` <code>${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}</code>` : '';
|
||
return `${SEVERITY_ICON[f.severity]} ${escapeHtml(f.title)}${where}`;
|
||
}
|
||
|
||
function checksLine(checks: CheckResult[]): string {
|
||
if (!checks.length) return '';
|
||
const parts = checks.map((c) => {
|
||
const icon = c.result === 'pass' ? '✅' : c.result === 'fail' ? '❌' : '⏭';
|
||
return `${escapeHtml(c.name)} ${icon}`;
|
||
});
|
||
return `<b>Checks:</b> ${parts.join(' · ')}`;
|
||
}
|
||
|
||
function truncate(text: string, max: number): string {
|
||
if (text.length <= max) return text;
|
||
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…';
|
||
}
|
||
|
||
export interface SummaryMeta {
|
||
ci: CiState;
|
||
/** Time the review took, for the footer. */
|
||
durationMin?: number;
|
||
}
|
||
|
||
/** The message the maintainer reads on the phone. HTML parse mode. */
|
||
export function formatTelegramSummary(pr: PrSummary, report: ReviewReport, meta: SummaryMeta): string {
|
||
const header =
|
||
`🔍 <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
|
||
`<i>by ${escapeHtml(pr.author)} · +${pr.additions}/−${pr.deletions} · ${pr.changedFiles} files · ${CI_LABEL[meta.ci]} · ${
|
||
pr.mergeable === 'CONFLICTING'
|
||
? 'conflicts ⚠️'
|
||
: pr.mergeable === 'MERGEABLE'
|
||
? 'mergeable'
|
||
: 'mergeability unknown'
|
||
}${pr.isDraft ? ' · draft' : ''}</i>\n` +
|
||
`<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n`;
|
||
const verdict = `\n<b>${VERDICT_LABEL[report.verdict]}</b> <i>(confidence ${report.confidence}${report.scope === 'mixed' ? ', mixed scope' : ''})</i>\n`;
|
||
const summary = report.summary ? `\n${escapeHtml(report.summary)}\n` : '';
|
||
|
||
const counts = countBySeverity(report.findings);
|
||
const countStr = SEVERITY_ORDER.filter((s) => counts[s] > 0)
|
||
.map((s) => `${counts[s]} ${s}${counts[s] === 1 ? '' : 's'}`)
|
||
.join(', ');
|
||
const findingsHeader = report.findings.length ? `\n<b>Findings</b> (${countStr}):\n` : '\n<b>Findings:</b> none\n';
|
||
|
||
const checks = checksLine(report.checks);
|
||
const recommendation = report.recommendation ? `\n<b>Recommendation:</b> ${escapeHtml(report.recommendation)}\n` : '';
|
||
const footer = meta.durationMin !== undefined ? `\n<i>review took ${meta.durationMin} min</i>` : '';
|
||
|
||
const fixed = header + verdict + summary + findingsHeader;
|
||
const tail = (checks ? `\n${checks}\n` : '') + recommendation + footer;
|
||
let budget = SUMMARY_BUDGET - fixed.length - tail.length;
|
||
|
||
const lines: string[] = [];
|
||
let shown = 0;
|
||
for (const f of report.findings) {
|
||
const line = findingLine(f) + '\n';
|
||
if (line.length > budget) break;
|
||
lines.push(line);
|
||
budget -= line.length;
|
||
shown++;
|
||
}
|
||
const hidden = report.findings.length - shown;
|
||
const more = hidden > 0 ? `<i>… ${hidden} more in the full report</i>\n` : '';
|
||
return fixed + lines.join('') + more + tail;
|
||
}
|
||
|
||
export function formatReviewFailure(
|
||
pr: Pick<PrSummary, 'number' | 'title' | 'author' | 'url'>,
|
||
reason: string
|
||
): string {
|
||
return (
|
||
`⚠️ <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
|
||
`<i>by ${escapeHtml(pr.author)}</i>\n<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n\n` +
|
||
`The review did not complete: ${escapeHtml(truncate(reason, 1500))}\n\n` +
|
||
`Use /review ${pr.number} to try again.`
|
||
);
|
||
}
|
||
|
||
/** Split on line boundaries so no chunk exceeds Telegram's cap. */
|
||
export function splitTelegramMessage(text: string, max = TELEGRAM_MAX): string[] {
|
||
if (text.length <= max) return [text];
|
||
const chunks: string[] = [];
|
||
let current = '';
|
||
for (const line of text.split('\n')) {
|
||
let piece = line;
|
||
while (piece.length > max) {
|
||
if (current) {
|
||
chunks.push(current);
|
||
current = '';
|
||
}
|
||
chunks.push(piece.slice(0, max));
|
||
piece = piece.slice(max);
|
||
}
|
||
const candidate = current ? `${current}\n${piece}` : piece;
|
||
if (candidate.length > max) {
|
||
chunks.push(current);
|
||
current = piece;
|
||
} else {
|
||
current = candidate;
|
||
}
|
||
}
|
||
if (current) chunks.push(current);
|
||
return chunks;
|
||
}
|
||
|
||
export interface InlineButton {
|
||
text: string;
|
||
callback_data: string;
|
||
}
|
||
|
||
/** Callback data is capped at 64 bytes by Telegram; these stay far under it. */
|
||
export function buildReportKeyboard(prNumber: number, opts: { ci: CiState; hasDraft: boolean }): InlineButton[][] {
|
||
const rows: InlineButton[][] = [
|
||
[
|
||
{ text: '📄 Full report', callback_data: `report:${prNumber}` },
|
||
...(opts.hasDraft ? [{ text: '💬 Draft comment', callback_data: `draft:${prNumber}` }] : []),
|
||
{ text: '🔁 Re-review', callback_data: `review:${prNumber}` },
|
||
],
|
||
[
|
||
{ text: '✅ Merge', callback_data: `merge:${prNumber}` },
|
||
...(opts.hasDraft ? [{ text: '📮 Post comment', callback_data: `post:${prNumber}` }] : []),
|
||
{ text: '🗑 Close', callback_data: `close:${prNumber}` },
|
||
],
|
||
];
|
||
if (opts.ci === 'awaiting-approval')
|
||
rows.push([{ text: '▶️ Approve CI run', callback_data: `approveci:${prNumber}` }]);
|
||
return rows;
|
||
}
|
||
|
||
export function confirmKeyboard(action: string, prNumber: number, nonce: string): InlineButton[][] {
|
||
return [
|
||
[
|
||
{ text: `Yes, ${action} #${prNumber}`, callback_data: `confirm:${action}:${prNumber}:${nonce}` },
|
||
{ text: 'Cancel', callback_data: `cancel:${action}:${prNumber}:${nonce}` },
|
||
],
|
||
];
|
||
}
|
||
|
||
export interface StatusRow {
|
||
number: number;
|
||
title: string;
|
||
author: string;
|
||
verdict?: Verdict;
|
||
status: string;
|
||
ci?: CiState;
|
||
mergeable: PrSummary['mergeable'];
|
||
isDraft: boolean;
|
||
}
|
||
|
||
export function formatStatusList(rows: StatusRow[], paused: boolean): string {
|
||
if (!rows.length) return 'No open pull requests.';
|
||
const lines = rows.map((r) => {
|
||
const v = r.verdict
|
||
? VERDICT_LABEL[r.verdict].split(' ')[0]
|
||
: r.status === 'reviewing'
|
||
? '⏳'
|
||
: r.status === 'queued'
|
||
? '🕓'
|
||
: '·';
|
||
const flags = [
|
||
r.ci ? CI_LABEL[r.ci].replace('CI ', '') : '',
|
||
r.mergeable === 'CONFLICTING' ? 'conflicts' : '',
|
||
r.isDraft ? 'draft' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
return `${v} <b>#${r.number}</b> ${escapeHtml(truncate(r.title, 60))} <i>(${escapeHtml(r.author)}${flags ? `; ${flags}` : ''})</i>`;
|
||
});
|
||
return `${paused ? '⏸ auto-review paused\n' : ''}<b>Open PRs (${rows.length})</b>\n${lines.join('\n')}`;
|
||
}
|
||
|
||
/**
|
||
* Backlog order for a fresh sweep: the ones you can act on first (mergeable, small),
|
||
* conflicting and huge ones last. Ties keep the newer PR first.
|
||
*/
|
||
export function orderBacklog<T extends Pick<PrSummary, 'number' | 'mergeable' | 'additions' | 'deletions'>>(
|
||
prs: T[]
|
||
): T[] {
|
||
const size = (p: T) => p.additions + p.deletions;
|
||
return [...prs].sort((a, b) => {
|
||
const ca = a.mergeable === 'CONFLICTING' ? 1 : 0;
|
||
const cb = b.mergeable === 'CONFLICTING' ? 1 : 0;
|
||
if (ca !== cb) return ca - cb;
|
||
const sa = size(a);
|
||
const sb = size(b);
|
||
if (sa !== sb) return sa - sb;
|
||
return b.number - a.number;
|
||
});
|
||
}
|
||
|
||
export function verdictLabel(v: Verdict): string {
|
||
return VERDICT_LABEL[v];
|
||
}
|