/** * @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 { 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