From f33b37c0080dee1793848a670c7bae1b609faa24 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 5 Sep 2026 19:55:50 +0200 Subject: [PATCH] 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-`) 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 --- CLAUDE.md | 3 + config/knip.json | 1 + config/tsconfig.pr-bot.json | 11 + docs/pr-bot.md | 145 ++++ package.json | 13 +- scripts/pr-bot/bot.ts | 1052 ++++++++++++++++++++++++++++++ scripts/pr-bot/codeman-client.ts | 268 ++++++++ scripts/pr-bot/config.ts | 194 ++++++ scripts/pr-bot/github.ts | 230 +++++++ scripts/pr-bot/main.ts | 281 ++++++++ scripts/pr-bot/report.ts | 368 +++++++++++ scripts/pr-bot/review-task.ts | 241 +++++++ scripts/pr-bot/state.ts | 163 +++++ scripts/pr-bot/telegram.ts | 188 ++++++ scripts/pr-bot/worktree.ts | 253 +++++++ test/pr-bot-commands.test.ts | 237 +++++++ test/pr-bot-report.test.ts | 370 +++++++++++ test/pr-bot-state.test.ts | 84 +++ 18 files changed, 4096 insertions(+), 6 deletions(-) create mode 100644 config/tsconfig.pr-bot.json create mode 100644 docs/pr-bot.md create mode 100644 scripts/pr-bot/bot.ts create mode 100644 scripts/pr-bot/codeman-client.ts create mode 100644 scripts/pr-bot/config.ts create mode 100644 scripts/pr-bot/github.ts create mode 100644 scripts/pr-bot/main.ts create mode 100644 scripts/pr-bot/report.ts create mode 100644 scripts/pr-bot/review-task.ts create mode 100644 scripts/pr-bot/state.ts create mode 100644 scripts/pr-bot/telegram.ts create mode 100644 scripts/pr-bot/worktree.ts create mode 100644 test/pr-bot-commands.test.ts create mode 100644 test/pr-bot-report.test.ts create mode 100644 test/pr-bot-state.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5c742fd1..7db88134 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation | | Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) | | Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` | +| PR review bot (maintainer) | `npm run pr-bot -- check` / `scan` / `review [--no-telegram]` / `run` / `install-service`. Reviews open PRs in Codeman sessions and reports over Telegram; `docs/pr-bot.md` | | Multi-user accounts | `codeman users add ` / `passwd ` / `list` / `rm ` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) | **CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 5 Playwright tests; globs live in `config/test-suites.ts`). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). @@ -223,6 +224,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest. +**PR bot** (`scripts/pr-bot/`, maintainer tooling, NOT part of the server; `docs/pr-bot.md`): a daemon (`codeman-pr-bot` user unit) that lists open PRs with `gh`, reviews each head commit once in a Codeman claude session named `prbot-` running in a private `git clone --shared` under `~/.codeman/pr-bot/worktrees/` (a clone, NOT a linked worktree: Claude Code reads a linked worktree's project settings from the MAIN checkout, so its `opus[1m]` pin silently overrode the bot's `modelOverride`, measured), and reports verdict + ranked findings + recommendation to Telegram with buttons. ⚠️ It reviews on its own but **never writes to GitHub on its own**: merge / close / post-comment / approve-CI happen only from a Telegram command or button from the configured chat, and merge/close/post take a second confirmation tap (`runConfirmed` in `bot.ts` is the one write site). ⚠️ The shared checkout is never checked out or reset by it (it only fetches into `refs/pr-bot/`, which also anchors the clone's objects against gc), a clone's `node_modules` is a SYMLINK into the main checkout unless the PR changes the lockfile (then the link is unlinked before `npm ci`), and `src/web/public/vendor` is copied per file, never linked, because postinstall regenerates it in place. Readiness/end-of-turn follow the codeman skill's rules (composer first, trust dialog read off the screen, `stop,blocked,exit` never `idle`). The Telegram token + chat id come from the existing notifier bot's `~/codeman-cases/telegram/.env`. Type-checked via `config/tsconfig.pr-bot.json` (part of `npm run typecheck`), linted/formatted with `src/`; tests `test/pr-bot-report.test.ts`, `test/pr-bot-state.test.ts`, `test/pr-bot-commands.test.ts` (the confirm-before-write flows against stubbed `gh`/Telegram). + **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. ⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. ⚠️ The layout does not replace `PUT /api/session-order`, it PROJECTS onto it: `tab-layout-legacy-order.ts` is the pure translation both ways (`putLegacyOrder()` recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout. ⚠️ **Reconciliation is gated on a SUCCESSFUL restore** (`markRestorationComplete` / `markRestorationFailed` / `markRestorationSkipped`, plus `assertDeletionReady()`): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched. `PUT` takes exactly `{baseVersion, layout}` (any other key shape is a validation error), answers a stale `baseVersion` with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts `tab:layoutChanged`, owner-routed via `deriveTabLayoutSseHint`. diff --git a/config/knip.json b/config/knip.json index 922a0d96..aab5ffef 100644 --- a/config/knip.json +++ b/config/knip.json @@ -4,6 +4,7 @@ "scripts/*.mjs", "scripts/*.js", "scripts/watch-subagents.ts", + "scripts/pr-bot/main.ts", "scripts/remotion/Root.tsx", "scripts/remotion/index.ts", "test/**/*.test.ts", diff --git a/config/tsconfig.pr-bot.json b/config/tsconfig.pr-bot.json new file mode 100644 index 00000000..2f50001d --- /dev/null +++ b/config/tsconfig.pr-bot.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["../scripts/pr-bot/**/*.ts"] +} diff --git a/docs/pr-bot.md b/docs/pr-bot.md new file mode 100644 index 00000000..04117576 --- /dev/null +++ b/docs/pr-bot.md @@ -0,0 +1,145 @@ +# PR bot: automatic pull-request reviews, reported over Telegram + +The PR bot is maintainer tooling that lives in `scripts/pr-bot/`. It watches the +repository's open pull requests, reviews each one in a Codeman claude session running in +a private clone of the repository, and sends the verdict to a Telegram chat with the ranked +findings, a recommendation and action buttons. The maintainer decides what happens next +from the phone: merge, post the drafted review comment, close, approve a waiting CI run, +or ask the reviewer session a follow-up question. + +It reviews on its own. It never writes to GitHub on its own. + +## How a review runs + +1. Every poll (default 10 minutes) the bot lists open PRs with `gh`. A PR is queued + when its head commit differs from the one last reviewed, so a push re-reviews and an + untouched PR is never reviewed twice. Draft PRs and bot PRs are skipped. The backlog + is ordered mergeable-and-small first, conflicting-and-huge last. +2. The PR head is fetched into a private ref (`refs/pr-bot/`) of the main repository + and checked out (detached) in a private clone under + `~/.codeman/pr-bot/worktrees/pr-`, made with `git clone --shared` so the object + store stays shared and nothing is duplicated. The maintainer's own checkout is never + checked out or reset by the bot. A clone rather than a linked worktree because Claude + Code reads a linked worktree's project settings from the MAIN checkout, whose model + pin would silently override the bot's. `node_modules` is a symlink to the main + checkout's tree when the PR itself leaves the dependency files untouched (judged + against the PR's merge base, not against current master), and a real `npm ci` + otherwise (the symlink is unlinked first, so npm can never write through it; an + install interrupted by a restart is discarded, never reused). +3. A review brief is written to `~/.codeman/pr-bot/jobs/pr-/brief.md`: the PR + metadata, CI state, mergeability, the file list, the body verbatim, the ground rules + (nothing reaches GitHub, no installs, no builds, no services, never port 3000), the + review protocol (CLAUDE.md and CONTRIBUTING first, then correctness, security, + invariants, tests, contract, scope), the checks to run, the verdict vocabulary and + the exact JSON to produce. +4. A Codeman session named `prbot-` is created in the clone over the HTTP API, + the composer is awaited (the folder-trust dialog is read off the screen and answered + one key at a time), and one prompt points the session at the brief. The bot waits on + the `stop`/`blocked`/`exit` hook signals, never on the heuristic `idle`, with a hard + timeout (default 40 minutes). +5. The session writes `report.json` and `report.md` next to the brief and replies + `REVIEW COMPLETE`. The bot parses the JSON leniently, records the Claude session id + for follow-ups, deletes the Codeman session, keeps the clone, and sends the + summary to Telegram. Reviews run one at a time. + +Verdicts: `merge`, `merge-with-fixes`, `request-changes`, `close`, `needs-discussion`. +Findings are ranked `blocker` / `major` / `minor` / `nit`, each with file and line. + +## The Telegram side + +Each review arrives as one message: PR number and title, author, size, CI state, +mergeability, the verdict with confidence, the summary, the top findings, the checks +that were run, the recommendation, and buttons: + +| Button / command | What it does | +| --- | --- | +| 📄 Full report · `/report N` | Sends `report.md` (as a file when long). | +| 💬 Draft comment · `/draft N` | Shows the comment drafted for the contributor. Nothing is posted. | +| 📮 Post comment · `/post N` | Shows the draft again and asks for confirmation, then posts it under your GitHub account. | +| ✅ Merge · `/merge N` | Re-checks mergeability and CI, lists warnings (red CI, new commits since the review, a non-merge verdict), asks for confirmation, then merges with a merge commit. Refuses a conflicting PR. | +| 🗑 Close · `/close N reason` | Asks for the closing comment if none was given, asks for confirmation, then closes with that comment. | +| ▶️ Approve CI run · `/approve N` | Approves a workflow run that GitHub holds for a first-time contributor. Shown only when one is waiting. | +| 🔁 Re-review · `/review N` | Queues a fresh review at the front of the queue. | +| `/ask N question`, or reply to any review message | Resumes the reviewer's Claude conversation in the same clone and relays the answer. It can inspect, run checks, or make uncommitted changes there; it still never pushes. | +| `/status` · `/scan` · `/pause` · `/resume` · `/help` | Housekeeping. | + +Merge, close and post always take a second tap. Confirmations expire after 15 minutes. +Only messages from the configured chat are acted on; anyone else gets silence. + +When a PR is merged or closed, the bot announces it, removes the clone and the +private ref, and keeps the record. + +## Setup + +Requirements on the machine that runs the bot: a running Codeman (the sessions are +spawned there), `gh` logged in as the account that should merge and comment, `git`, +Node 22, and the repository checkout with its `node_modules`. + +Config is `~/.codeman/pr-bot.env` (`KEY=VALUE`, keep it mode 0600). The Telegram token +and chat id are read from the existing notifier bot's env file +(`~/codeman-cases/telegram/.env`) when present, so on the maintainer's machine no key +has to be copied; set them here to use a different bot. + +| Key | Default | Meaning | +| --- | --- | --- | +| `TELEGRAM_BOT_TOKEN` | from the shared env file | BotFather token. | +| `TELEGRAM_CHAT_ID` | from the shared env file | The one chat that receives reports and may issue commands. | +| `GITHUB_REPO` | `Ark0N/Codeman` | `owner/name`. | +| `CODEMAN_API_URL` | `https://127.0.0.1:3000` | The Codeman that spawns the review sessions. A self-signed certificate is accepted. | +| `CODEMAN_USERNAME` / `CODEMAN_PASSWORD` | unset | Only when that Codeman has a password. | +| `PR_BOT_POLL_INTERVAL` | `600` | Seconds between GitHub polls (minimum 60). | +| `PR_BOT_MAIN_CHECKOUT` | the repo this script is in | The repository the clones share objects with and fetch from. | +| `PR_BOT_DATA_DIR` | `~/.codeman/pr-bot` | State, briefs, reports, clones. | +| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `claude-fable-5-1`. | +| `PR_BOT_EFFORT` | unset | Codeman `effort` for the review sessions. | +| `PR_BOT_REVIEW_TIMEOUT` | `40` | Minutes before a review is abandoned. | +| `PR_BOT_FOLLOWUP_TIMEOUT` | `20` | Minutes before a follow-up is abandoned. | +| `PR_BOT_AUTO_REVIEW` | `1` | `0` reviews only on `/review N`. | +| `PR_BOT_REVIEW_DRAFTS` | `0` | `1` reviews draft PRs too. | +| `PR_BOT_TELEGRAM_ENV_FILE` | `~/codeman-cases/telegram/.env` | Where the shared token and chat id are read from. | + +```bash +npm run pr-bot -- check # config, gh, git, Codeman, Telegram, open PR count +npm run pr-bot -- scan # the open PRs in review order, with what is new +npm run pr-bot -- review 383 --no-telegram # one review now, printed instead of sent +npm run pr-bot -- run # the daemon +npm run pr-bot -- install-service # systemd user unit codeman-pr-bot, enabled and started +npm run pr-bot -- status # what the state file knows +tail -f ~/.codeman/pr-bot/bot.log # the service logs to a file, not the journal +``` + +## Safety properties worth knowing before changing it + +- **GitHub writes happen in exactly one place** (`runConfirmed` in `bot.ts`) and only + after a confirmation tap on a nonce that expires. The review session's brief forbids + `gh` writes, pushes and merges, and the session has no reason to have the token + anyway: it runs as the same user as the maintainer's own sessions, so the prompt rule + is the guard, and the clone's checkout is detached so an accidental push has no + branch to land on. +- **The maintainer's checkout is shared with other agent sessions**, so the bot never + runs `git checkout`, `reset`, `stash` or `clean` there. It only fetches into + `refs/pr-bot/*` there; everything else happens inside the per-PR clone. +- **The clones are `git clone --shared`.** Their objects live in the main checkout, so + the `refs/pr-bot/` ref there is what keeps a PR's commits safe from `git gc`; it + is deleted together with the clone when the PR closes. +- **`node_modules` may be a symlink into the live checkout.** The brief forbids + installs, and `worktree.ts` unlinks the symlink before any `npm ci`. `src/web/public/vendor` + is copied per file, never linked, because postinstall regenerates it in place. +- **Sessions are named `prbot-`** and tracked by id; the bot deletes only those, on + completion, on shutdown, and (by name) as a sweep at startup after a crash. It never + touches the maintainer's `w-*` sessions. +- **Readiness and end-of-turn follow the codeman skill's rules**: composer first + (`shift+tab` in the pane), trust dialog read from the screen, `stop,blocked,exit` + signals rather than `idle`. A session that asks a question is reported as a failed + review with the pane's last lines, not left hanging. +- **Telegram input is data.** Command parsing is a fixed grammar; free text is only ever + relayed to a reviewer session as the maintainer's own follow-up, or used as a closing + comment after confirmation. + +Tests: `test/pr-bot-report.test.ts` (parsing, formatting, CI classification, command +grammar, trust-dialog reader, config), `test/pr-bot-state.test.ts`, and +`test/pr-bot-commands.test.ts` (the command and confirmation flows against a stubbed +`gh` and Telegram: a GitHub write happens once, after the tap, never for a foreign chat +or a reused nonce). Type-checked by +`npm run typecheck` through `config/tsconfig.pr-bot.json`, linted and formatted with +the main sources. diff --git a/package.json b/package.json index b26242e9..7fe27866 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,19 @@ "test:mobile": "vitest run --config test/mobile/vitest.config.ts", "check:frontend-syntax": "node scripts/check-frontend-syntax.mjs", "fix:node-pty": "node scripts/fix-node-pty.mjs", - "typecheck": "tsc --noEmit", - "lint": "eslint --config config/eslint.config.js 'src/**/*.ts'", - "lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix", - "format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'", - "format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'", + "typecheck": "tsc --noEmit && tsc -p config/tsconfig.pr-bot.json", + "lint": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts'", + "lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' --fix", + "format": "prettier --write 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'", + "format:check": "prettier --check 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'", "check:public-assets": "node scripts/check-public-assets.mjs", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs", "changeset": "changeset", "version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs", "check:lockfile": "node scripts/check-lockfile-sync.mjs", "knip": "npx --yes knip@latest --config config/knip.json", - "release": "changeset publish" + "release": "changeset publish", + "pr-bot": "tsx scripts/pr-bot/main.ts" }, "prettier": { "singleQuote": true, diff --git a/scripts/pr-bot/bot.ts b/scripts/pr-bot/bot.ts new file mode 100644 index 00000000..7d48750d --- /dev/null +++ b/scripts/pr-bot/bot.ts @@ -0,0 +1,1052 @@ +/** + * @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 + * worktree, 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; +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(); + 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; + if (needsReview && !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)}`)); + 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; + if (existsSync(reportJsonPath)) report = parseReport(extractJsonObject(readFileSync(reportJsonPath, 'utf8'))); + if (!report) { + const 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)); + + const durationMin = Math.max(1, Math.round((Date.now() - started) / 60_000)); + Object.assign(rec, { + status: 'reviewed', + 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; + await this.telegram + .sendMessage(formatReviewFailure(rec, reason)) + .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): Promise { + const minutes = Math.round((Date.now() - started) / 60_000); + 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 without writing report.json'; + } + } + + // ---- 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; + } + } + 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); + await this.telegram.sendMessage(`🎉 Merged #${n}${rec ? ` · ${escapeHtml(rec.title)}` : ''}.`); + this.scheduleScan(5000); + return; + } + case 'close': { + await closePr(this.cfg.githubRepo, n, pending.reason ?? ''); + 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))}`); + } + } +} diff --git a/scripts/pr-bot/codeman-client.ts b/scripts/pr-bot/codeman-client.ts new file mode 100644 index 00000000..0efde9c5 --- /dev/null +++ b/scripts/pr-bot/codeman-client.ts @@ -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( + method: string, + path: string, + body?: unknown, + query?: Record, + timeoutMs = 60_000 + ): Promise { + 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 = { 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 = {}; + 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 { + const data = await this.request('GET', '/api/sessions'); + return Array.isArray(data) ? data : (data.sessions ?? []); + } + + async getSession(id: string): Promise { + return this.request('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.request('POST', `/api/sessions/${id}/input`, { input, useMux: true, clientId, seq }); + } + + async lastResponse(id: string): Promise { + 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 { + 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 { + 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' }; +} diff --git a/scripts/pr-bot/config.ts b/scripts/pr-bot/config.ts new file mode 100644 index 00000000..93d75c0f --- /dev/null +++ b/scripts/pr-bot/config.ts @@ -0,0 +1,194 @@ +/** + * @fileoverview PR bot configuration. + * + * Read from `~/.codeman/pr-bot.env` (KEY=VALUE lines, mode 0600, the same shape as + * the data dir's `.env`) with the process environment layered on top, then validated + * into a typed config. `parseEnvFile` and `buildConfig` are pure so the validation + * rules are unit-testable without touching the filesystem. + * + * Nothing here reads Codeman's own settings: the bot is maintainer tooling that + * drives a running Codeman over HTTP, it is not part of the server. + */ +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +export interface PrBotConfig { + /** Telegram bot token from BotFather. */ + telegramBotToken: string; + /** The ONE chat the bot talks to and accepts commands from. Everything else is ignored. */ + telegramChatId: string; + /** `owner/name` of the repository whose PRs are reviewed. */ + githubRepo: string; + /** Codeman server the review sessions are spawned on. */ + codemanApiUrl: string; + codemanUsername?: string; + codemanPassword?: string; + /** How often open PRs are listed. */ + pollIntervalMs: number; + /** The maintainer's checkout; worktrees are added from its git dir. Never checked out by the bot. */ + mainCheckout: string; + /** State, reports and worktrees live under here. */ + dataDir: string; + worktreesDir: string; + /** Optional model / effort for the review sessions (Codeman `modelOverride` / `effort`). */ + model?: string; + effort?: string; + /** Hard ceiling for one review turn. */ + reviewTimeoutMs: number; + /** Hard ceiling for one follow-up turn. */ + followupTimeoutMs: number; + /** When false, PRs are only reviewed on an explicit `/review N`. */ + autoReview: boolean; + /** Draft PRs are skipped unless this is on. */ + reviewDrafts: boolean; +} + +export const CONFIG_FILE_NAME = 'pr-bot.env'; + +/** + * The maintainer's existing Telegram notifier bot (a separate, send-only process) + * keeps its token and chat id here. The PR bot shares that bot identity by default, + * so it reads those two keys from the same file rather than making anyone copy a + * secret around. Override with `PR_BOT_TELEGRAM_ENV_FILE`. + */ +export const DEFAULT_TELEGRAM_ENV_FILE = join('codeman-cases', 'telegram', '.env'); +const SHARED_TELEGRAM_KEYS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'] as const; + +/** The keys the env file understands, for `check` and the docs. */ +export const CONFIG_KEYS = [ + 'TELEGRAM_BOT_TOKEN', + 'TELEGRAM_CHAT_ID', + 'GITHUB_REPO', + 'CODEMAN_API_URL', + 'CODEMAN_USERNAME', + 'CODEMAN_PASSWORD', + 'PR_BOT_POLL_INTERVAL', + 'PR_BOT_MAIN_CHECKOUT', + 'PR_BOT_DATA_DIR', + 'PR_BOT_MODEL', + 'PR_BOT_EFFORT', + 'PR_BOT_REVIEW_TIMEOUT', + 'PR_BOT_FOLLOWUP_TIMEOUT', + 'PR_BOT_AUTO_REVIEW', + 'PR_BOT_REVIEW_DRAFTS', + 'PR_BOT_TELEGRAM_ENV_FILE', +] as const; + +/** Parse `KEY=VALUE` lines. Comments, blanks, `export ` prefixes and matching quotes are handled. */ +export function parseEnvFile(text: string): Record { + const out: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line + .slice(0, eq) + .trim() + .replace(/^export\s+/, ''); + let value = line.slice(eq + 1).trim(); + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) value = value.slice(1, -1); + } + if (/^[A-Z_][A-Z0-9_]*$/.test(key)) out[key] = value; + } + return out; +} + +function intFrom(raw: string | undefined, fallback: number, min: number): number { + const n = parseInt(raw ?? '', 10); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.max(min, n); +} + +function flagFrom(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined || raw === '') return fallback; + return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase()); +} + +/** Build the typed config from an env map. Throws with every missing key named at once. */ +export function buildConfig( + env: Record, + defaults: { home: string; repoRoot: string } +): PrBotConfig { + const missing: string[] = []; + const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim() ?? ''; + const telegramChatId = env.TELEGRAM_CHAT_ID?.trim() ?? ''; + if (!telegramBotToken) missing.push('TELEGRAM_BOT_TOKEN'); + if (!telegramChatId) missing.push('TELEGRAM_CHAT_ID'); + if (missing.length) throw new Error(`pr-bot config is missing: ${missing.join(', ')}`); + + const githubRepo = env.GITHUB_REPO?.trim() || 'Ark0N/Codeman'; + if (!/^[\w.-]+\/[\w.-]+$/.test(githubRepo)) throw new Error(`GITHUB_REPO must be owner/name, got "${githubRepo}"`); + + const codemanApiUrl = (env.CODEMAN_API_URL?.trim() || 'https://127.0.0.1:3000').replace(/\/+$/, ''); + if (!/^https?:\/\//.test(codemanApiUrl)) + throw new Error(`CODEMAN_API_URL must be http(s)://..., got "${codemanApiUrl}"`); + + const dataDir = resolve(env.PR_BOT_DATA_DIR?.trim() || join(defaults.home, '.codeman', 'pr-bot')); + const mainCheckout = resolve(env.PR_BOT_MAIN_CHECKOUT?.trim() || defaults.repoRoot); + + return { + telegramBotToken, + telegramChatId, + githubRepo, + codemanApiUrl, + codemanUsername: env.CODEMAN_USERNAME?.trim() || undefined, + codemanPassword: env.CODEMAN_PASSWORD || undefined, + pollIntervalMs: intFrom(env.PR_BOT_POLL_INTERVAL, 600, 60) * 1000, + mainCheckout, + dataDir, + worktreesDir: join(dataDir, 'worktrees'), + model: env.PR_BOT_MODEL?.trim() || undefined, + effort: env.PR_BOT_EFFORT?.trim() || undefined, + reviewTimeoutMs: intFrom(env.PR_BOT_REVIEW_TIMEOUT, 40, 5) * 60_000, + followupTimeoutMs: intFrom(env.PR_BOT_FOLLOWUP_TIMEOUT, 20, 2) * 60_000, + autoReview: flagFrom(env.PR_BOT_AUTO_REVIEW, true), + reviewDrafts: flagFrom(env.PR_BOT_REVIEW_DRAFTS, false), + }; +} + +/** The repository this script lives in (scripts/pr-bot/ -> repo root). */ +export function scriptRepoRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +} + +export function configFilePath(): string { + return join(process.env.CODEMAN_DATA_DIR || join(homedir(), '.codeman'), CONFIG_FILE_NAME); +} + +export function telegramEnvFilePath(fromFile: Record): string { + return resolve( + process.env.PR_BOT_TELEGRAM_ENV_FILE || + fromFile.PR_BOT_TELEGRAM_ENV_FILE || + join(homedir(), DEFAULT_TELEGRAM_ENV_FILE) + ); +} + +/** + * Layers, lowest first: the shared Telegram notifier's `.env` (token + chat id only), + * then `~/.codeman/pr-bot.env`, then the process environment, so a one-off + * `PR_BOT_MODEL=... npx tsx ...` wins over everything. + */ +export function loadConfig(): PrBotConfig { + const file = configFilePath(); + const fromFile = existsSync(file) ? parseEnvFile(readFileSync(file, 'utf8')) : {}; + const sharedFile = telegramEnvFilePath(fromFile); + const shared = existsSync(sharedFile) ? parseEnvFile(readFileSync(sharedFile, 'utf8')) : {}; + const merged: Record = {}; + for (const key of SHARED_TELEGRAM_KEYS) if (shared[key]) merged[key] = shared[key]; + Object.assign(merged, fromFile); + for (const key of CONFIG_KEYS) { + const v = process.env[key]; + if (v !== undefined && v !== '') merged[key] = v; + } + try { + return buildConfig(merged, { home: homedir(), repoRoot: scriptRepoRoot() }); + } catch (err) { + throw new Error(`${(err as Error).message} (config file: ${file}; shared Telegram env: ${sharedFile})`); + } +} diff --git a/scripts/pr-bot/github.ts b/scripts/pr-bot/github.ts new file mode 100644 index 00000000..69dce2d7 --- /dev/null +++ b/scripts/pr-bot/github.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview GitHub access for the PR bot, entirely through the `gh` CLI. + * + * `gh` carries the maintainer's own login, so the bot needs no token of its own and + * every write (merge, close, comment, CI approval) lands under that account. That is + * why every write here is only ever reached from an explicit, confirmed Telegram + * command (see bot.ts); nothing in this file is called on a timer. + * + * `classifyCi` and `latestRunPerWorkflow` are pure and unit-tested. + */ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface PrSummary { + number: number; + title: string; + author: string; + headSha: string; + baseRef: string; + headRef: string; + isDraft: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeState: string; + additions: number; + deletions: number; + changedFiles: number; + updatedAt: string; + url: string; + isCrossRepository: boolean; + labels: string[]; +} + +export interface PrFile { + path: string; + additions: number; + deletions: number; +} + +export interface PrDetail extends PrSummary { + body: string; + files: PrFile[]; + authorAssociation: string; + linkedIssues: { number: number; title: string }[]; + commitCount: number; + commentCount: number; + reviewDecision: string; + headRepo: string; +} + +export interface WorkflowRun { + id: number; + name: string; + status: string; + conclusion: string | null; +} + +export type CiState = 'passed' | 'failed' | 'pending' | 'awaiting-approval' | 'none'; + +export interface CiStatus { + state: CiState; + runs: WorkflowRun[]; +} + +const PR_LIST_FIELDS = + 'number,title,author,headRefOid,baseRefName,headRefName,isDraft,mergeable,mergeStateStatus,additions,deletions,changedFiles,updatedAt,url,isCrossRepository,labels'; + +export async function gh(args: string[], opts: { timeoutMs?: number; input?: string } = {}): Promise { + const child = execFileAsync('gh', args, { + maxBuffer: 32 * 1024 * 1024, + timeout: opts.timeoutMs ?? 60_000, + env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' }, + }); + if (opts.input !== undefined && child.child.stdin) { + child.child.stdin.end(opts.input); + } + const { stdout } = await child; + return stdout; +} + +interface RawPr { + number: number; + title: string; + author?: { login?: string }; + headRefOid: string; + baseRefName: string; + headRefName: string; + isDraft: boolean; + mergeable: string; + mergeStateStatus: string; + additions: number; + deletions: number; + changedFiles: number; + updatedAt: string; + url: string; + isCrossRepository: boolean; + labels?: { name: string }[]; +} + +function toSummary(raw: RawPr): PrSummary { + const mergeable = raw.mergeable === 'MERGEABLE' || raw.mergeable === 'CONFLICTING' ? raw.mergeable : 'UNKNOWN'; + return { + number: raw.number, + title: raw.title ?? '', + author: raw.author?.login ?? 'unknown', + headSha: raw.headRefOid, + baseRef: raw.baseRefName, + headRef: raw.headRefName, + isDraft: Boolean(raw.isDraft), + mergeable, + mergeState: raw.mergeStateStatus ?? 'UNKNOWN', + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + changedFiles: raw.changedFiles ?? 0, + updatedAt: raw.updatedAt ?? '', + url: raw.url, + isCrossRepository: Boolean(raw.isCrossRepository), + labels: (raw.labels ?? []).map((l) => l.name), + }; +} + +export async function listOpenPrs(repo: string): Promise { + const out = await gh(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', '100', '--json', PR_LIST_FIELDS]); + const raw = JSON.parse(out) as RawPr[]; + return raw.map(toSummary); +} + +export async function getPrDetail(repo: string, number: number): Promise { + const fields = `${PR_LIST_FIELDS},body,files,commits,comments,reviewDecision,closingIssuesReferences,headRepository,headRepositoryOwner`; + const out = await gh(['pr', 'view', String(number), '--repo', repo, '--json', fields]); + const raw = JSON.parse(out) as RawPr & { + body?: string; + files?: { path: string; additions: number; deletions: number }[]; + commits?: unknown[]; + comments?: unknown[]; + reviewDecision?: string; + closingIssuesReferences?: { number: number; title: string }[]; + headRepository?: { name?: string }; + headRepositoryOwner?: { login?: string }; + }; + let authorAssociation = 'NONE'; + try { + const assoc = await gh(['api', `repos/${repo}/pulls/${number}`, '--jq', '.author_association']); + authorAssociation = assoc.trim() || 'NONE'; + } catch { + // Metadata only; a failed lookup must not fail the review. + } + const owner = raw.headRepositoryOwner?.login; + const name = raw.headRepository?.name; + return { + ...toSummary(raw), + body: raw.body ?? '', + files: (raw.files ?? []).map((f) => ({ path: f.path, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })), + authorAssociation, + linkedIssues: (raw.closingIssuesReferences ?? []).map((i) => ({ number: i.number, title: i.title })), + commitCount: raw.commits?.length ?? 0, + commentCount: raw.comments?.length ?? 0, + reviewDecision: raw.reviewDecision ?? '', + headRepo: owner && name ? `${owner}/${name}` : '', + }; +} + +/** The API returns newest first; keep only the newest run of each workflow. */ +export function latestRunPerWorkflow(runs: WorkflowRun[]): WorkflowRun[] { + const seen = new Set(); + const out: WorkflowRun[] = []; + for (const run of runs) { + if (seen.has(run.name)) continue; + seen.add(run.name); + out.push(run); + } + return out; +} + +/** + * Collapse workflow runs into one word the report can show. `action_required` is + * the fork-PR case where GitHub waits for a maintainer to approve the run: the PR + * looks unchecked and stays that way until someone clicks, so it gets its own state. + */ +export function classifyCi(runs: WorkflowRun[]): CiState { + const latest = latestRunPerWorkflow(runs); + if (latest.length === 0) return 'none'; + if (latest.some((r) => r.conclusion === 'action_required')) return 'awaiting-approval'; + if (latest.some((r) => ['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(r.status))) + return 'pending'; + if (latest.some((r) => ['failure', 'timed_out', 'cancelled', 'startup_failure'].includes(r.conclusion ?? ''))) + return 'failed'; + if (latest.every((r) => ['success', 'skipped', 'neutral'].includes(r.conclusion ?? ''))) return 'passed'; + return 'pending'; +} + +export async function getCiStatus(repo: string, headSha: string): Promise { + const out = await gh([ + 'api', + `repos/${repo}/actions/runs?head_sha=${headSha}&event=pull_request&per_page=30`, + '--jq', + '[.workflow_runs[] | {id, name, status, conclusion}]', + ]); + const runs = JSON.parse(out) as WorkflowRun[]; + return { state: classifyCi(runs), runs: latestRunPerWorkflow(runs) }; +} + +export async function approveWorkflowRun(repo: string, runId: number): Promise { + await gh(['api', '-X', 'POST', `repos/${repo}/actions/runs/${runId}/approve`]); +} + +/** Merge commits, matching the repository's history (`Merge pull request #N from ...`). */ +export async function mergePr(repo: string, number: number): Promise { + return gh(['pr', 'merge', String(number), '--repo', repo, '--merge'], { timeoutMs: 120_000 }); +} + +export async function closePr(repo: string, number: number, comment: string): Promise { + const args = ['pr', 'close', String(number), '--repo', repo]; + if (comment.trim()) args.push('--comment', comment); + return gh(args); +} + +export async function commentPr(repo: string, number: number, body: string): Promise { + return gh(['pr', 'comment', String(number), '--repo', repo, '--body-file', '-'], { input: body }); +} + +export async function ghAuthOk(): Promise { + try { + await gh(['auth', 'status']); + return true; + } catch { + return false; + } +} diff --git a/scripts/pr-bot/main.ts b/scripts/pr-bot/main.ts new file mode 100644 index 00000000..6445c737 --- /dev/null +++ b/scripts/pr-bot/main.ts @@ -0,0 +1,281 @@ +#!/usr/bin/env -S npx tsx +/** + * @fileoverview CLI entry for the PR bot. + * + * npx tsx scripts/pr-bot/main.ts run # the daemon (what the service runs) + * npx tsx scripts/pr-bot/main.ts check # config, gh, Codeman, Telegram, git + * npx tsx scripts/pr-bot/main.ts scan # list open PRs and what would be queued + * npx tsx scripts/pr-bot/main.ts review N [--no-telegram] # one review, now + * npx tsx scripts/pr-bot/main.ts status # what the state file knows + * npx tsx scripts/pr-bot/main.ts notify N # resend PR N's review message to Telegram + * npx tsx scripts/pr-bot/main.ts install-service # systemd user unit, enabled + started + * npx tsx scripts/pr-bot/main.ts uninstall-service + * + * User guide: docs/pr-bot.md + */ +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { PrBot, type TelegramLike } from './bot.js'; +import { CodemanClient } from './codeman-client.js'; +import { configFilePath, loadConfig, type PrBotConfig } from './config.js'; +import { ghAuthOk, listOpenPrs } from './github.js'; +import { orderBacklog } from './report.js'; +import { StateStore } from './state.js'; +import { TelegramClient } from './telegram.js'; + +const SERVICE_NAME = 'codeman-pr-bot'; + +function log(msg: string): void { + console.log(`${new Date().toISOString()} ${msg}`); +} + +/** Prints what the bot would have sent; used by `review --no-telegram`. */ +class ConsoleTelegram implements TelegramLike { + private nextId = 1; + isOurChat(): boolean { + return true; + } + async sendMessage(text: string): Promise { + console.log(`\n--- telegram (html) ---\n${text}\n---`); + return this.nextId++; + } + async sendPlain(text: string): Promise { + console.log(`\n--- telegram (plain) ---\n${text}\n---`); + return this.nextId++; + } + async editReplyMarkup(): Promise {} + async deleteMessage(): Promise {} + async answerCallback(): Promise {} + async sendDocument(filename: string, content: string): Promise { + console.log(`\n--- telegram document ${filename} (${content.length} chars) ---`); + } + async getUpdates(): Promise<[]> { + return []; + } + async setMyCommands(): Promise {} +} + +function makeCodeman(cfg: PrBotConfig): CodemanClient { + return new CodemanClient({ apiUrl: cfg.codemanApiUrl, username: cfg.codemanUsername, password: cfg.codemanPassword }); +} + +export function logFilePath(cfg: PrBotConfig): string { + return join(cfg.dataDir, 'bot.log'); +} + +function unitFile(cfg: PrBotConfig): string { + const tsx = join(cfg.mainCheckout, 'node_modules', '.bin', 'tsx'); + // A user service gets a minimal PATH, which is where `gh` (and an nvm/Homebrew + // node) are not: the first run failed its scan with `spawn gh ENOENT`. Bake the + // installing shell's PATH in, as `codeman service install` does. + const seen = new Set(); + const path = (process.env.PATH || '/usr/local/bin:/usr/bin:/bin') + .split(':') + .filter((p) => p && !p.endsWith('/node_modules/.bin') && !seen.has(p) && seen.add(p)) + .join(':'); + return `[Unit] +Description=Codeman PR review bot (Telegram) +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=${cfg.mainCheckout} +ExecStart=${tsx} scripts/pr-bot/main.ts run +Restart=always +RestartSec=15 +Environment=HOME=${homedir()} +Environment=NODE_ENV=production +Environment=PATH=${path} +# A file rather than the journal: on some boxes \`journalctl --user\` cannot read +# the user journal at all, and a review bot whose logs cannot be found is not +# debuggable from a phone. +StandardOutput=append:${logFilePath(cfg)} +StandardError=append:${logFilePath(cfg)} +SyslogIdentifier=${SERVICE_NAME} + +[Install] +WantedBy=default.target +`; +} + +async function cmdCheck(): Promise { + const cfg = loadConfig(); + console.log( + `config file: ${configFilePath()}${existsSync(configFilePath()) ? '' : ' (absent, defaults + shared Telegram env)'}` + ); + console.log(`repo: ${cfg.githubRepo}`); + console.log(`codeman: ${cfg.codemanApiUrl}`); + console.log(`main checkout: ${cfg.mainCheckout}`); + console.log(`data dir: ${cfg.dataDir}`); + console.log(`model: ${cfg.model ?? '(session default)'}, effort: ${cfg.effort ?? '(default)'}`); + console.log( + `poll: every ${cfg.pollIntervalMs / 60_000} min; review timeout ${cfg.reviewTimeoutMs / 60_000} min; auto-review ${cfg.autoReview}` + ); + let ok = true; + const step = async (name: string, fn: () => Promise) => { + try { + console.log(`✔ ${name}: ${await fn()}`); + } catch (err) { + ok = false; + console.log(`✘ ${name}: ${(err as Error).message}`); + } + }; + await step('gh auth', async () => + (await ghAuthOk()) ? 'logged in' : Promise.reject(new Error('run `gh auth login`')) + ); + await step('git', async () => + execFileSync('git', ['-C', cfg.mainCheckout, 'rev-parse', '--git-dir'], { encoding: 'utf8' }).trim() + ); + await step('codeman', async () => { + const s = await makeCodeman(cfg).status(); + return `up (version ${s.version ?? 'unknown'})`; + }); + await step('telegram', async () => { + const me = await new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId).getMe(); + return `@${me.username ?? '?'} for chat ${cfg.telegramChatId}`; + }); + await step('open PRs', async () => `${(await listOpenPrs(cfg.githubRepo)).length}`); + if (!ok) process.exit(1); +} + +async function cmdScan(): Promise { + const cfg = loadConfig(); + const store = new StateStore(join(cfg.dataDir, 'state.json')); + const open = await listOpenPrs(cfg.githubRepo); + const rows = orderBacklog(open).map((pr) => { + const rec = store.pr(pr.number); + const state = + rec?.reviewedSha === pr.headSha ? `reviewed (${rec?.verdict ?? '?'})` : rec?.reviewedSha ? 'updated' : 'new'; + const flags = [pr.isDraft ? 'draft' : '', pr.mergeable === 'CONFLICTING' ? 'conflicts' : ''] + .filter(Boolean) + .join(', '); + return `#${pr.number}\t${state}\t+${pr.additions}/-${pr.deletions}\t${pr.author}\t${pr.title}${flags ? ` [${flags}]` : ''}`; + }); + console.log(`${open.length} open PRs in review order:\n${rows.join('\n')}`); +} + +async function cmdStatus(): Promise { + const cfg = loadConfig(); + const store = new StateStore(join(cfg.dataDir, 'state.json')); + console.log(`paused: ${store.state.paused}; telegram offset: ${store.state.telegramOffset}`); + for (const rec of Object.values(store.state.prs).sort((a, b) => b.number - a.number)) { + console.log( + `#${rec.number}\t${rec.status}\t${rec.verdict ?? '-'}\t${rec.reviewedSha?.slice(0, 8) ?? '-'}\t${rec.author}\t${rec.title}${ + rec.lastError ? `\n\t${rec.lastError.split('\n')[0]}` : '' + }` + ); + } +} + +async function cmdReview(args: string[]): Promise { + const number = parseInt(args.find((a) => /^\d+$/.test(a)) ?? '', 10); + if (!Number.isFinite(number)) throw new Error('usage: review [--no-telegram]'); + const cfg = loadConfig(); + const telegram = args.includes('--no-telegram') + ? new ConsoleTelegram() + : new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId); + const bot = new PrBot(cfg, { telegram, codeman: makeCodeman(cfg), log }); + const rec = await bot.reviewPr(number); + console.log( + `\n#${number}: ${rec.status}${rec.verdict ? ` (${rec.verdict})` : ''}${rec.lastError ? `\n${rec.lastError}` : ''}` + ); + if (rec.reportMdPath) console.log(`report: ${rec.reportMdPath}`); + process.exit(rec.status === 'reviewed' ? 0 : 1); +} + +async function cmdNotify(args: string[]): Promise { + const number = parseInt(args[0] ?? '', 10); + if (!Number.isFinite(number)) throw new Error('usage: notify '); + const cfg = loadConfig(); + const bot = new PrBot(cfg, { + telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId), + codeman: makeCodeman(cfg), + log, + }); + const rec = bot.store.pr(number); + if (!rec?.report) throw new Error(`no review of #${number} in ${cfg.dataDir}`); + await bot.sendSummary(rec); + console.log(`sent the review message for #${number}`); +} + +async function cmdRun(): Promise { + const cfg = loadConfig(); + const bot = new PrBot(cfg, { + telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId), + codeman: makeCodeman(cfg), + log, + }); + let stopping = false; + const shutdown = (signal: string) => { + if (stopping) return; + stopping = true; + log(`${signal}: stopping`); + bot + .stop() + .catch((err) => log(`stop: ${(err as Error).message}`)) + .finally(() => process.exit(0)); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + log(`starting: repo ${cfg.githubRepo}, codeman ${cfg.codemanApiUrl}, data ${cfg.dataDir}`); + await bot.start(); +} + +function cmdInstallService(): void { + const cfg = loadConfig(); + const dir = join(homedir(), '.config', 'systemd', 'user'); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `${SERVICE_NAME}.service`); + mkdirSync(cfg.dataDir, { recursive: true }); + writeFileSync(path, unitFile(cfg)); + execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' }); + execFileSync('systemctl', ['--user', 'enable', SERVICE_NAME], { stdio: 'inherit' }); + // `restart` rather than `enable --now`: a re-install must pick up the new unit. + execFileSync('systemctl', ['--user', 'restart', SERVICE_NAME], { stdio: 'inherit' }); + console.log(`installed ${path}\nlogs: tail -f ${logFilePath(cfg)}`); +} + +function cmdUninstallService(): void { + const path = join(homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`); + execFileSync('systemctl', ['--user', 'disable', '--now', SERVICE_NAME], { stdio: 'inherit' }); + if (existsSync(path)) execFileSync('rm', ['-f', path]); + execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' }); + console.log(`removed ${SERVICE_NAME}`); +} + +async function main(): Promise { + const [cmd = 'run', ...rest] = process.argv.slice(2); + switch (cmd) { + case 'run': + return cmdRun(); + case 'check': + return cmdCheck(); + case 'scan': + return cmdScan(); + case 'status': + return cmdStatus(); + case 'review': + return cmdReview(rest); + case 'notify': + return cmdNotify(rest); + case 'install-service': + return cmdInstallService(); + case 'uninstall-service': + return cmdUninstallService(); + default: + console.error( + 'usage: main.ts run | check | scan | status | review [--no-telegram] | install-service | uninstall-service' + ); + process.exit(2); + } +} + +main().catch((err) => { + console.error((err as Error).stack ?? String(err)); + process.exit(1); +}); diff --git a/scripts/pr-bot/report.ts b/scripts/pr-bot/report.ts new file mode 100644 index 00000000..d4a45208 --- /dev/null +++ b/scripts/pr-bot/report.ts @@ -0,0 +1,368 @@ +/** + * @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 = { blocker: '🔴', major: '🟠', minor: '🟡', nit: '⚪' }; +const VERDICT_LABEL: Record = { + merge: '✅ MERGE', + 'merge-with-fixes': '🟢 MERGE WITH FIXES', + 'request-changes': '🟠 REQUEST CHANGES', + close: '❌ CLOSE', + 'needs-discussion': '💬 NEEDS DISCUSSION', +}; +const CI_LABEL: Record = { + 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, '>'); +} + +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; + 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; + 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; + 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 { + const out: Record = { 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 ? ` ${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}` : ''; + 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 `Checks: ${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 = + `🔍 PR #${pr.number} · ${escapeHtml(truncate(pr.title, 120))}\n` + + `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' : ''}\n` + + `${escapeHtml(pr.url)}\n`; + const verdict = `\n${VERDICT_LABEL[report.verdict]} (confidence ${report.confidence}${report.scope === 'mixed' ? ', mixed scope' : ''})\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 ? `\nFindings (${countStr}):\n` : '\nFindings: none\n'; + + const checks = checksLine(report.checks); + const recommendation = report.recommendation ? `\nRecommendation: ${escapeHtml(report.recommendation)}\n` : ''; + const footer = meta.durationMin !== undefined ? `\nreview took ${meta.durationMin} min` : ''; + + 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 ? `… ${hidden} more in the full report\n` : ''; + return fixed + lines.join('') + more + tail; +} + +export function formatReviewFailure( + pr: Pick, + reason: string +): string { + return ( + `⚠️ PR #${pr.number} · ${escapeHtml(truncate(pr.title, 120))}\n` + + `by ${escapeHtml(pr.author)}\n${escapeHtml(pr.url)}\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} #${r.number} ${escapeHtml(truncate(r.title, 60))} (${escapeHtml(r.author)}${flags ? `; ${flags}` : ''})`; + }); + return `${paused ? '⏸ auto-review paused\n' : ''}Open PRs (${rows.length})\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>( + 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]; +} diff --git a/scripts/pr-bot/review-task.ts b/scripts/pr-bot/review-task.ts new file mode 100644 index 00000000..b3192f8a --- /dev/null +++ b/scripts/pr-bot/review-task.ts @@ -0,0 +1,241 @@ +/** + * @fileoverview The review brief handed to each reviewer session, and the follow-up + * brief. Pure: the bot writes the result to a file and sends the session one short + * line pointing at it (prompts are single-line over tmux, and a brief this size + * belongs on disk anyway). + * + * The brief is opinionated on purpose. It names the repository's own rules (CLAUDE.md, + * CONTRIBUTING.md), the checks to run, the verdict vocabulary, and the exact JSON the + * bot parses. Everything the maintainer would say out loud before delegating a + * review lives here. + */ +import type { CiStatus, PrDetail } from './github.js'; + +export const VERDICTS = ['merge', 'merge-with-fixes', 'request-changes', 'close', 'needs-discussion'] as const; +export type Verdict = (typeof VERDICTS)[number]; + +export interface ReviewBriefInput { + pr: PrDetail; + ci: CiStatus; + mergeBase: string; + worktreeDir: string; + mainCheckout: string; + reportJsonPath: string; + reportMdPath: string; +} + +function ciLine(ci: CiStatus): string { + const detail = ci.runs.map((r) => `${r.name}: ${r.conclusion ?? r.status}`).join(', '); + switch (ci.state) { + case 'passed': + return `passed (${detail})`; + case 'failed': + return `FAILED (${detail}); read the failing job's log with \`gh run view --log-failed\` before you trust or dismiss it`; + case 'pending': + return `still running (${detail})`; + case 'awaiting-approval': + return 'never ran: the workflow is waiting for a maintainer to approve it (first-time contributor), so run the checks yourself'; + default: + return 'no workflow runs found for this head (a conflicting PR gets no CI at all); run the checks yourself'; + } +} + +export function buildReviewBrief(input: ReviewBriefInput): string { + const { pr, ci, mergeBase, worktreeDir, mainCheckout, reportJsonPath, reportMdPath } = input; + const files = pr.files.map((f) => `- \`${f.path}\` (+${f.additions}/-${f.deletions})`).join('\n'); + const linked = pr.linkedIssues.length + ? pr.linkedIssues.map((i) => `- #${i.number} ${i.title}`).join('\n') + : '- none linked'; + const mergeability = + pr.mergeable === 'CONFLICTING' + ? 'CONFLICTING with master. It cannot be merged as-is and GitHub runs no CI for it. Review the PR head as it stands, and say in the report whether the conflicts look mechanical or structural (`git merge-tree` against origin/master helps).' + : pr.mergeable === 'MERGEABLE' + ? 'mergeable' + : 'unknown (GitHub has not computed it yet)'; + + return `# Review brief: PR #${pr.number} ${pr.title} + +You are reviewing a pull request against Codeman on behalf of the maintainer. You are +in a private clone at \`${worktreeDir}\`, checked out (detached) at the PR head. The +maintainer reads your report on a phone and decides what happens next, so write for +someone who has not seen the diff. + +## Ground rules (read twice) + +- Nothing you do here reaches GitHub. Do NOT push, comment, merge, close, label, or + create anything with \`gh\`; \`gh\` is for READING only (\`gh pr view\`, \`gh run view\`, + \`gh api\` GETs). +- Do NOT run \`npm install\`, \`npm ci\`, \`npm update\` or \`npm run build\`: \`node_modules\` + may be a symlink into the maintainer's live checkout. Everything else in package.json + scripts is fine (\`npm run typecheck\`, \`npm run lint\`, \`npm test -- \`, ...). +- Do NOT restart, stop or install any service, and never bind port 3000: the + maintainer's production Codeman runs there. Test ports are 3150 and up. +- \`${mainCheckout}\` is the maintainer's shared checkout. You may READ it for comparison; + never run a git command there that changes anything (no checkout, reset, stash, clean). +- Stay inside this clone for writes. Do not create files elsewhere except the two + report files named below. +- Do not ask questions. Nobody is watching this session. Where something is ambiguous, + decide, and list the assumption in the report. + +## The pull request + +- **#${pr.number}** ${pr.title} +- Author: ${pr.author} (${pr.authorAssociation.toLowerCase().replace(/_/g, ' ')})${pr.headRepo ? `, from \`${pr.headRepo}\`` : ''} +- URL: ${pr.url} +- Base: \`${pr.baseRef}\` at merge base \`${mergeBase.slice(0, 12)}\`; head: \`${pr.headSha.slice(0, 12)}\` (${pr.commitCount} commits) +- Size: +${pr.additions} / -${pr.deletions} across ${pr.changedFiles} files +- Mergeability: ${mergeability} +- CI: ${ciLine(ci)} +- Draft: ${pr.isDraft ? 'yes' : 'no'}; existing comments: ${pr.commentCount}${pr.labels.length ? `; labels: ${pr.labels.join(', ')}` : ''} + +### Linked issues +${linked} + +### Files changed +${files || '- (none reported)'} + +### PR description, verbatim +\`\`\`text +${pr.body.trim() || '(empty)'} +\`\`\` + +## How to review + +1. Read \`CLAUDE.md\` at the root and \`.github/CONTRIBUTING.md\`. Most review feedback on + this repository traces back to a rule already written there, and a change that + contradicts one of those rules is a finding even when the code works. Open the + \`docs/architecture-invariants.md\` sections the change touches. +2. Understand the change: \`git log --oneline ${mergeBase.slice(0, 12)}..HEAD\` and + \`git diff ${mergeBase.slice(0, 12)}..HEAD\`. Read the surrounding code, not only the + hunks: the file's \`@fileoverview\` first, then the call sites of anything changed. +3. Look for, in this order: correctness bugs (wrong logic, races, missed error paths, + lost state across restart); security (auth and ownership checks, path confinement, + the env-prefix allowlist, shell/command injection, SSRF, secrets on the command + line or in state files); violations of CLAUDE.md rules (cite the rule); behaviour + changes without tests; contract changes (\`/api/v1\` paths, response envelope, + \`errorCode\` values, SSE event names are public and stable, see + \`docs/versioning-policy.md\`); scope (one change per PR: flag unrelated changes + bundled in); docs and registries that must move with the code (CLAUDE.md and + architecture-invariants when a rule changes, \`sse-events.ts\` and \`constants.js\` + parity, \`docs/api-reference.md\`); housekeeping that does not belong in a PR + (version bumps, CHANGELOG edits, files pulled back into Prettier's scope, committed + vendor bundles, changeset files are fine). +4. Run the checks and record what you ran and what came back: + \`npm run typecheck\`, \`npm run lint\`, \`npm run check:frontend-syntax\`, + \`npm run format:check\`, then the tests covering the touched areas + (\`npm test -- test/.test.ts\`, several files at once is fine). Run the full + \`npm test\` when the change is broad or touches shared infrastructure (session, + tmux, routes, state); it takes minutes, which is acceptable. A red check that is + also red on origin/master is not the PR's fault: say so rather than blaming it. + Other test suites may be running on this machine at the same time and they share + the 3150+ port range, so re-run a failed file on its own (\`npm test -- \`) + before you read an EADDRINUSE or a timeout as the PR's regression. +5. Verify before you report. A finding that could be a misread must be confirmed by + reading the full code path, by a tiny test, or by running it. Every finding names a + file and line. Rank: **blocker** (must be fixed before merge: data loss, security, + breaks a documented invariant, breaks the build or tests), **major** (should be + fixed: a real bug in an edge the PR introduces, a missing test for new behaviour), + **minor**, **nit**. +6. Judge the PR, not the author. Contributors here are volunteers and the maintainer + thanks them by name in every release; be exact and be kind. + +## Verdict vocabulary + +- \`merge\`: no blockers or majors, checks green; merge as-is. +- \`merge-with-fixes\`: mergeable, but with small things the maintainer would rather fix + at merge time than round-trip (list them so they can be applied on top). +- \`request-changes\`: blockers or majors the author should fix. +- \`close\`: wrong direction, superseded, or not wanted; say what should happen instead. +- \`needs-discussion\`: a design question the maintainer must answer before anyone + spends more time (name the question). + +## Output, mandatory + +Write BOTH files, then reply with exactly one line: \`REVIEW COMPLETE\`. + +1. \`${reportJsonPath}\`: a single JSON object, no markdown fences, this shape: + +\`\`\`json +{ + "verdict": "merge | merge-with-fixes | request-changes | close | needs-discussion", + "confidence": "high | medium | low", + "summary": "Two or three sentences: what the PR does, and the review's bottom line.", + "changes": ["one bullet per thing the PR actually changes"], + "findings": [ + { + "severity": "blocker | major | minor | nit", + "title": "one line", + "file": "path/from/repo/root.ts", + "line": 123, + "detail": "what is wrong, why it matters, what to do instead", + "invariant": "the CLAUDE.md / CONTRIBUTING rule it breaks, or omit" + } + ], + "checks": [ + { "name": "typecheck", "command": "npm run typecheck", "result": "pass | fail | skipped", "notes": "" } + ], + "_checks_note": "result is from the PR's point of view: a regression test you deliberately ran against master to prove it fails is a pass (say so in notes), a red run caused by another suite on the machine is skipped with the reason, only a genuine problem with the PR is fail", + "scope": "focused | mixed", + "risk": "One or two sentences naming the judgment calls a second reviewer should look at.", + "recommendation": "Two to four sentences for the maintainer: what to do next and why.", + "draftComment": "A comment to the contributor, in markdown, ready to post (rules below).", + "assumptions": ["anything you had to decide alone"] +} +\`\`\` + +2. \`${reportMdPath}\`: the full report in markdown for the maintainer, in this order: + what the PR does; the verdict with the reasoning; findings in severity order with + file:line and the fix; checks run with results; CLAUDE.md rules touched; scope and + risk; recommendation; assumptions. Include the diff stat. No length limit, but no + padding either. + +### Draft comment rules + +The draft is written AS the maintainer TO the contributor and must stand alone: the +reader has not seen this brief. Open by thanking them and saying in one sentence what +the PR does. Then the findings that need action, each with file:line and the concrete +ask, blockers first. Close with what happens next (merge after fixes, will fix at merge +time, and so on). When the verdict is \`merge\`, the whole comment is a short thank-you +naming anything you would touch at merge time. Plain markdown. No em-dashes (use +commas, colons or parentheses). No emojis. No "Generated with Claude Code" or similar +attribution line. No hedging words. The maintainer reads it before it is posted and may +edit it. +`; +} + +/** Sent as ONE line; the brief above is on disk. */ +export function reviewKickoffLine(briefPath: string): string { + return `Read ${briefPath} and carry out the review it describes. Do not ask questions. Finish by writing both report files it names, then reply with exactly: REVIEW COMPLETE`; +} + +export function followupKickoffLine(followupPath: string): string { + return `Read ${followupPath}: it holds a follow-up from the maintainer about the pull request you reviewed. Do what it asks within the ground rules of the original brief (no pushing, no gh writes, no npm install, no builds, no services), then answer in plain text. Do not ask questions.`; +} + +export function buildFollowupBrief(input: { + prNumber: number; + title: string; + instruction: string; + worktreeDir: string; + reportMdPath: string; + briefPath: string; +}): string { + return `# Follow-up on PR #${input.prNumber} ${input.title} + +The maintainer read your review report (\`${input.reportMdPath}\`; the original brief is +\`${input.briefPath}\`, and its ground rules still apply: nothing reaches GitHub, no +installs, no builds, no services, writes stay inside \`${input.worktreeDir}\`). + +Their message: + +\`\`\`text +${input.instruction.trim()} +\`\`\` + +Answer concisely and concretely, for a phone screen: lead with the answer, then the +evidence (commands run, file:line). If the message asks you to change code, make the +change in this clone, run the relevant checks, and describe the diff (\`git diff +--stat\` plus the essential hunks). Keep the changes uncommitted unless asked to commit; +never push. If it asks for something outside the ground rules, say so and stop. +`; +} diff --git a/scripts/pr-bot/state.ts b/scripts/pr-bot/state.ts new file mode 100644 index 00000000..4ce8db0c --- /dev/null +++ b/scripts/pr-bot/state.ts @@ -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; + pending: Record; + /** Telegram message id -> PR number, so a reply to any of the bot's messages finds its PR. */ + messages: Record; + /** Telegram message id -> PR number for "reply with the closing reason" prompts. */ + reasonPrompts: Record; +} + +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; + 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]; + } +} diff --git a/scripts/pr-bot/telegram.ts b/scripts/pr-bot/telegram.ts new file mode 100644 index 00000000..54ff9455 --- /dev/null +++ b/scripts/pr-bot/telegram.ts @@ -0,0 +1,188 @@ +/** + * @fileoverview Minimal Telegram Bot API client (long polling, no webhook: the box sits + * behind Tailscale) plus the pure command / callback parsers. + * + * Only updates from the configured chat are ever acted on; everything else is dropped + * without an answer, so a stranger who finds the bot gets silence, not a menu. + */ + +export interface TelegramMessage { + message_id: number; + chat: { id: number | string }; + from?: { id: number; username?: string }; + text?: string; + reply_to_message?: { message_id: number; text?: string }; +} + +export interface TelegramCallbackQuery { + id: string; + from: { id: number; username?: string }; + message?: TelegramMessage; + data?: string; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; +} + +export interface SendOptions { + replyMarkup?: unknown; + replyToMessageId?: number; + disablePreview?: boolean; +} + +export class TelegramClient { + private readonly base: string; + + constructor( + token: string, + private readonly chatId: string + ) { + this.base = `https://api.telegram.org/bot${token}`; + } + + private async call(method: string, body?: Record, timeoutMs = 30_000): Promise { + const res = await fetch(`${this.base}/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(timeoutMs), + }); + const json = (await res.json()) as { ok: boolean; result?: T; description?: string }; + if (!json.ok) throw new Error(`telegram ${method}: ${json.description ?? res.status}`); + return json.result as T; + } + + isOurChat(chatId: number | string | undefined): boolean { + return chatId !== undefined && String(chatId) === this.chatId; + } + + async getMe(): Promise<{ username?: string }> { + return this.call<{ username?: string }>('getMe'); + } + + async sendMessage(text: string, opts: SendOptions = {}): Promise { + const result = await this.call<{ message_id: number }>('sendMessage', { + chat_id: this.chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: opts.disablePreview ?? true, + reply_markup: opts.replyMarkup, + reply_to_message_id: opts.replyToMessageId, + }); + return result.message_id; + } + + /** Plain text, no parse mode: for content the bot did not write (reviewer answers, drafts). */ + async sendPlain(text: string, opts: SendOptions = {}): Promise { + const result = await this.call<{ message_id: number }>('sendMessage', { + chat_id: this.chatId, + text, + disable_web_page_preview: opts.disablePreview ?? true, + reply_markup: opts.replyMarkup, + reply_to_message_id: opts.replyToMessageId, + }); + return result.message_id; + } + + async editReplyMarkup(messageId: number, replyMarkup: unknown): Promise { + try { + await this.call('editMessageReplyMarkup', { + chat_id: this.chatId, + message_id: messageId, + reply_markup: replyMarkup, + }); + } catch (err) { + // "message is not modified" is Telegram's way of saying the keyboard already looks like that. + if (!String(err).includes('not modified')) throw err; + } + } + + async deleteMessage(messageId: number): Promise { + try { + await this.call('deleteMessage', { chat_id: this.chatId, message_id: messageId }); + } catch { + // Already gone, or older than Telegram allows a bot to delete; the message was informational. + } + } + + async answerCallback(callbackId: string, text?: string): Promise { + await this.call('answerCallbackQuery', { callback_query_id: callbackId, text }); + } + + async sendDocument(filename: string, content: string, caption?: string): Promise { + const form = new FormData(); + form.set('chat_id', this.chatId); + if (caption) form.set('caption', caption); + form.set('document', new Blob([content], { type: 'text/markdown' }), filename); + const res = await fetch(`${this.base}/sendDocument`, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(60_000), + }); + const json = (await res.json()) as { ok: boolean; description?: string }; + if (!json.ok) throw new Error(`telegram sendDocument: ${json.description ?? res.status}`); + } + + async getUpdates(offset: number, timeoutSec: number): Promise { + return this.call( + 'getUpdates', + { offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] }, + (timeoutSec + 15) * 1000 + ); + } + + async setMyCommands(commands: { command: string; description: string }[]): Promise { + await this.call('setMyCommands', { commands }); + } +} + +export interface ParsedCommand { + command: string; + prNumber?: number; + rest: string; +} + +/** `/merge 381 force` -> {command:'merge', prNumber:381, rest:'force'}; `/help@botname` is handled. */ +export function parseCommand(text: string | undefined): ParsedCommand | null { + if (!text) return null; + const m = text.trim().match(/^\/([a-zA-Z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/); + if (!m) return null; + const command = m[1].toLowerCase(); + const argText = (m[2] ?? '').trim(); + const numMatch = argText.match(/^#?(\d+)\b\s*([\s\S]*)$/); + if (numMatch) return { command, prNumber: parseInt(numMatch[1], 10), rest: numMatch[2].trim() }; + return { command, rest: argText }; +} + +export interface ParsedCallback { + action: string; + prNumber: number; + nonce?: string; + /** For confirm/cancel: the action being confirmed. */ + target?: string; +} + +export function parseCallback(data: string | undefined): ParsedCallback | null { + if (!data) return null; + const parts = data.split(':'); + if (parts[0] === 'confirm' || parts[0] === 'cancel') { + if (parts.length !== 4) return null; + const prNumber = parseInt(parts[2], 10); + if (!Number.isFinite(prNumber)) return null; + return { action: parts[0], target: parts[1], prNumber, nonce: parts[3] }; + } + if (parts.length !== 2) return null; + const prNumber = parseInt(parts[1], 10); + if (!Number.isFinite(prNumber)) return null; + return { action: parts[0], prNumber }; +} + +/** Find the PR number a report message is about, from its first line (`🔍 PR #381 · ...`). */ +export function prNumberFromMessageText(text: string | undefined): number | null { + if (!text) return null; + const m = text.match(/PR #(\d+)/); + return m ? parseInt(m[1], 10) : null; +} diff --git a/scripts/pr-bot/worktree.ts b/scripts/pr-bot/worktree.ts new file mode 100644 index 00000000..a8b23787 --- /dev/null +++ b/scripts/pr-bot/worktree.ts @@ -0,0 +1,253 @@ +/** + * @fileoverview Per-PR checkouts for the review sessions. + * + * The maintainer's checkout is SHARED with other agent sessions (CLAUDE.md, Session + * Safety), so the bot never runs `git checkout` there. It fetches the PR head into a + * private ref (`refs/pr-bot/`) of the main repository, which anchors the objects, + * and checks the PR out in a private clone under the bot's own data dir; every + * in-tree git command runs with `-C `. + * + * Why a `git clone --shared` and not a linked worktree: Claude Code resolves a linked + * worktree's project settings through the git common dir, i.e. the MAIN checkout's + * `.claude/settings.local.json`, whose model pin then silently overrides anything + * written into the worktree (measured 2026-09-05: a worktree pinned to + * `claude-fable-5-1` reported `claude-opus-5[1m]`). A shared clone has its own + * project root, so Codeman's `modelOverride` and hooks land where the CLI reads them, + * while `objects/info/alternates` keeps the object store shared (no duplication). + * + * Dependencies: a clone has no `node_modules`. When the PR leaves the lockfile + * untouched, `node_modules` is a SYMLINK to the main checkout's tree (read-only use: + * tsc, vitest, eslint). When the PR changes dependencies, the symlink is unlinked + * first and `npm ci` installs a real tree, so npm can never write through the link + * into the live server's modules. `src/web/public/vendor` is COPIED per file, never + * linked: postinstall regenerates it in place, and a link would let a PR's bundle + * overwrite the bundle the production server is serving. + */ +import { execFile } from 'child_process'; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface WorktreeInfo { + dir: string; + headSha: string; + mergeBase: string; + deps: 'linked' | 'installed' | 'kept'; +} + +export type Logger = (msg: string) => void; + +async function git(args: string[], cwd: string, timeoutMs = 120_000): Promise { + const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs }); + return stdout; +} + +export function prRef(prNumber: number): string { + return `refs/pr-bot/${prNumber}`; +} + +/** The upstream master, as fetched into the main repository, mirrored into the clone. */ +const MASTER_REF = 'refs/remotes/origin/master'; + +export function worktreeDirFor(worktreesDir: string, prNumber: number): string { + return join(worktreesDir, `pr-${prNumber}`); +} + +const DEP_FILES = [ + 'package.json', + 'package-lock.json', + 'packages/xterm-zerolag-input/package.json', + 'packages/gesture-control/package.json', +]; + +async function originUrl(mainCheckout: string): Promise { + return (await git(['remote', 'get-url', 'origin'], mainCheckout)).trim(); +} + +/** A linked worktree from the first version of this file: `.git` is a FILE there. */ +function isLegacyWorktree(dir: string): boolean { + const dotGit = join(dir, '.git'); + try { + return statSync(dotGit).isFile(); + } catch { + return false; + } +} + +function isOwnClone(dir: string): boolean { + try { + return statSync(join(dir, '.git')).isDirectory(); + } catch { + return false; + } +} + +/** Fetch the PR head, (re)create the clone at it, and make node_modules usable. */ +export async function preparePrWorktree(opts: { + mainCheckout: string; + worktreesDir: string; + prNumber: number; + /** Reset a reused clone to the fetched head (drops edits a follow-up may have made). */ + reset: boolean; + log: Logger; +}): Promise { + const { mainCheckout, worktreesDir, prNumber, log } = opts; + const ref = prRef(prNumber); + const dir = worktreeDirFor(worktreesDir, prNumber); + mkdirSync(worktreesDir, { recursive: true }); + + log(`fetching origin master + pull/${prNumber}/head`); + await git( + ['fetch', '--quiet', 'origin', `+refs/heads/master:${MASTER_REF}`, `+refs/pull/${prNumber}/head:${ref}`], + mainCheckout, + 300_000 + ); + const headSha = (await git(['rev-parse', ref], mainCheckout)).trim(); + + if (existsSync(dir) && isLegacyWorktree(dir)) { + log(`replacing the linked worktree at ${dir} with a clone`); + await git(['worktree', 'remove', '--force', dir], mainCheckout).catch(() => + rmSync(dir, { recursive: true, force: true }) + ); + await git(['worktree', 'prune'], mainCheckout); + } + if (existsSync(dir) && !isOwnClone(dir)) { + log(`removing stale directory ${dir}`); + rmSync(dir, { recursive: true, force: true }); + } + if (!existsSync(dir)) { + log(`cloning (shared objects) into ${dir}`); + await git(['clone', '--quiet', '--shared', '--no-checkout', mainCheckout, dir], mainCheckout, 300_000); + // `origin` of the clone should mean GitHub, like everywhere else, not the main + // checkout's path; the refs below are fetched from the main checkout by path. + await git(['remote', 'set-url', 'origin', await originUrl(mainCheckout)], dir); + } + // Mirror the two refs from the main repository (objects are already reachable via + // alternates, so this only moves refs). `+` because both can move backwards. + await git(['fetch', '--quiet', mainCheckout, `+${MASTER_REF}:${MASTER_REF}`, `+${ref}:${ref}`], dir); + const current = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'], dir).catch(() => '')).trim(); + if (current !== headSha) { + log(`checking out ${headSha.slice(0, 8)}${current ? ` (was ${current.slice(0, 8)})` : ''}`); + await git(['checkout', '--quiet', '--detach', ref], dir); + } + if (opts.reset) { + await git(['reset', '--hard', '--quiet', ref], dir); + } + + const mergeBase = (await git(['merge-base', MASTER_REF, 'HEAD'], dir)).trim(); + const deps = await ensureDependencies({ mainCheckout, dir, ref, mergeBase, log }); + ensureVendorCopy(mainCheckout, dir, log); + return { dir, headSha, mergeBase, deps }; +} + +/** Written into a clone's own node_modules once `npm ci` has finished; its absence means a half install. */ +const INSTALL_MARKER = '.pr-bot-installed'; + +async function ensureDependencies(opts: { + mainCheckout: string; + dir: string; + ref: string; + mergeBase: string; + log: Logger; +}): Promise { + const { mainCheckout, dir, ref, mergeBase, log } = opts; + const target = join(dir, 'node_modules'); + // Against the MERGE BASE, not master: master's own version bumps since the PR + // branched would otherwise make every older PR look like a dependency change and + // cost a full npm ci each. Only what the PR itself did to the dependency files counts. + let depsChanged = false; + try { + await git(['diff', '--quiet', mergeBase, ref, '--', ...DEP_FILES], mainCheckout); + } catch { + depsChanged = true; + } + + let existing = existsSync(target) || isSymlink(target) ? lstatSync(target) : null; + if (existing?.isDirectory() && !existsSync(join(target, INSTALL_MARKER))) { + // A real tree without the marker is an install that was interrupted (service + // restart mid `npm ci`); never trust it. + log('discarding an incomplete node_modules install'); + rmSync(target, { recursive: true, force: true }); + existing = null; + } + if (!depsChanged) { + if (existing?.isSymbolicLink()) return 'linked'; + if (existing?.isDirectory()) return 'kept'; + symlinkSync(join(mainCheckout, 'node_modules'), target, 'dir'); + log('node_modules linked to the main checkout (dependencies unchanged by the PR)'); + return 'linked'; + } + + // The PR changes dependencies: a real install, and NEVER through the symlink. + if (existing?.isSymbolicLink()) unlinkSync(target); + if (existing?.isDirectory()) return 'kept'; + log('the PR changes dependencies: running npm ci in the clone (this can take minutes)'); + await execFileAsync('npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], { + cwd: dir, + timeout: 20 * 60_000, + maxBuffer: 64 * 1024 * 1024, + }); + writeFileSync(join(target, INSTALL_MARKER), new Date().toISOString()); + return 'installed'; +} + +function isSymlink(path: string): boolean { + try { + return lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +function ensureVendorCopy(mainCheckout: string, dir: string, log: Logger): void { + const rel = join('src', 'web', 'public', 'vendor'); + const src = join(mainCheckout, rel); + const dst = join(dir, rel); + if (!existsSync(src)) return; + // Two of the vendor files are tracked in git, so the directory already exists in a + // fresh checkout; copy whatever is MISSING (the postinstall-built xterm bundles). + mkdirSync(dst, { recursive: true }); + let copied = 0; + for (const entry of readdirSync(src)) { + const target = join(dst, entry); + if (existsSync(target)) continue; + cpSync(join(src, entry), target, { recursive: true }); + copied++; + } + if (copied) log(`${copied} vendor bundle(s) copied from the main checkout`); +} + +export async function removePrWorktree(opts: { + mainCheckout: string; + worktreesDir: string; + prNumber: number; + log: Logger; +}): Promise { + const dir = worktreeDirFor(opts.worktreesDir, opts.prNumber); + if (existsSync(dir)) { + opts.log(`removing ${dir}`); + if (isLegacyWorktree(dir)) { + await git(['worktree', 'remove', '--force', dir], opts.mainCheckout).catch(() => undefined); + await git(['worktree', 'prune'], opts.mainCheckout).catch(() => undefined); + } + rmSync(dir, { recursive: true, force: true }); + } + try { + await git(['update-ref', '-d', prRef(opts.prNumber)], opts.mainCheckout); + } catch { + // The ref may never have been created; nothing to delete. + } +} diff --git a/test/pr-bot-commands.test.ts b/test/pr-bot-commands.test.ts new file mode 100644 index 00000000..e4fef134 --- /dev/null +++ b/test/pr-bot-commands.test.ts @@ -0,0 +1,237 @@ +/** + * @fileoverview The PR bot's Telegram command and button handling, driven through + * `PrBot.handleUpdate` with a recording Telegram stub and a mocked `gh` layer. Pins + * the one property that matters most: a GitHub write (merge, close, post) happens only + * after the confirmation tap, exactly once, and never for a foreign chat or a stale + * nonce. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const gh = vi.hoisted(() => ({ + listOpenPrs: vi.fn(async () => []), + getPrDetail: vi.fn(), + getCiStatus: vi.fn(async () => ({ state: 'passed', runs: [] })), + mergePr: vi.fn(async () => 'merged'), + closePr: vi.fn(async () => 'closed'), + commentPr: vi.fn(async () => 'commented'), + approveWorkflowRun: vi.fn(async () => undefined), + gh: vi.fn(async () => 'MERGED\n'), +})); +vi.mock('../scripts/pr-bot/github.js', () => gh); +vi.mock('../scripts/pr-bot/worktree.js', () => ({ + preparePrWorktree: vi.fn(), + removePrWorktree: vi.fn(async () => undefined), +})); + +import { PrBot, type TelegramLike } from '../scripts/pr-bot/bot.js'; +import { buildConfig } from '../scripts/pr-bot/config.js'; +import type { CodemanClient } from '../scripts/pr-bot/codeman-client.js'; +import type { PrDetail } from '../scripts/pr-bot/github.js'; +import type { ReviewReport } from '../scripts/pr-bot/report.js'; + +class FakeTelegram implements TelegramLike { + sent: { text: string; markup?: unknown; plain: boolean }[] = []; + edits: number[] = []; + private nextId = 100; + isOurChat(chatId: number | string | undefined): boolean { + return String(chatId) === '1'; + } + async sendMessage(text: string, opts: { replyMarkup?: unknown } = {}): Promise { + this.sent.push({ text, markup: opts.replyMarkup, plain: false }); + return this.nextId++; + } + async sendPlain(text: string): Promise { + this.sent.push({ text, plain: true }); + return this.nextId++; + } + async editReplyMarkup(messageId: number): Promise { + this.edits.push(messageId); + } + async deleteMessage(): Promise {} + async answerCallback(): Promise {} + async sendDocument(): Promise {} + async getUpdates(): Promise<[]> { + return []; + } + async setMyCommands(): Promise {} + last(): string { + return this.sent[this.sent.length - 1]?.text ?? ''; + } + /** The confirm callback_data of the last message's keyboard. */ + confirmData(): string { + const markup = this.sent[this.sent.length - 1]?.markup as { inline_keyboard: { callback_data: string }[][] }; + return markup.inline_keyboard.flat().find((b) => b.callback_data.startsWith('confirm:'))!.callback_data; + } +} + +function detail(over: Partial = {}): PrDetail { + return { + number: 381, + title: 'feat(web): base URL', + author: 'mtiller', + headSha: 'abc123abc123', + baseRef: 'master', + headRef: 'feat', + isDraft: false, + mergeable: 'MERGEABLE', + mergeState: 'CLEAN', + additions: 10, + deletions: 2, + changedFiles: 3, + updatedAt: '', + url: 'https://github.com/Ark0N/Codeman/pull/381', + isCrossRepository: true, + labels: [], + body: '', + files: [], + authorAssociation: 'CONTRIBUTOR', + linkedIssues: [], + commitCount: 1, + commentCount: 0, + reviewDecision: '', + headRepo: 'mtiller/Codeman', + ...over, + }; +} + +const report: ReviewReport = { + verdict: 'merge', + confidence: 'high', + summary: 's', + changes: [], + findings: [], + checks: [], + scope: 'focused', + risk: '', + recommendation: 'merge it', + draftComment: 'Thanks, merging.', + assumptions: [], +}; + +const msg = (text: string, chat = 1, replyTo?: { message_id: number; text?: string }) => ({ + update_id: 1, + message: { message_id: 7, chat: { id: chat }, text, reply_to_message: replyTo }, +}); +const cb = (data: string, chat = 1) => ({ + update_id: 2, + callback_query: { id: 'q', from: { id: 1 }, data, message: { message_id: 9, chat: { id: chat } } }, +}); + +describe('PrBot commands', () => { + let bot: PrBot; + let tg: FakeTelegram; + + beforeEach(() => { + vi.clearAllMocks(); + gh.getPrDetail.mockImplementation(async () => detail()); + const cfg = buildConfig( + { TELEGRAM_BOT_TOKEN: 't', TELEGRAM_CHAT_ID: '1', PR_BOT_DATA_DIR: mkdtempSync(join(tmpdir(), 'prbot-cmd-')) }, + { home: '/h', repoRoot: '/r' } + ); + tg = new FakeTelegram(); + bot = new PrBot(cfg, { telegram: tg, codeman: {} as CodemanClient, log: () => undefined }); + const rec = bot.store.upsertPr(detail()); + Object.assign(rec, { status: 'reviewed', reviewedSha: 'abc123abc123', verdict: 'merge', report }); + bot.store.save(); + }); + + afterEach(async () => { + await bot.stop(); + }); + + it('answers /help only for the configured chat', async () => { + await bot.handleUpdate(msg('/help', 2)); + expect(tg.sent).toHaveLength(0); + await bot.handleUpdate(msg('/help')); + expect(tg.last()).toContain('/merge N'); + }); + + it('shows the draft without posting it', async () => { + await bot.handleUpdate(msg('/draft 381')); + expect(tg.last()).toContain('Thanks, merging.'); + expect(tg.last()).toContain('not posted'); + expect(gh.commentPr).not.toHaveBeenCalled(); + }); + + it('merges only after the confirmation tap, once, and rejects a reused nonce', async () => { + await bot.handleUpdate(msg('/merge 381')); + expect(gh.mergePr).not.toHaveBeenCalled(); + expect(tg.last()).toContain('Merge #381'); + expect(Object.keys(bot.store.state.pending)).toHaveLength(1); + const data = tg.confirmData(); + expect(data).toMatch(/^confirm:merge:381:[0-9a-f]{8}$/); + + await bot.handleUpdate(cb(data)); + expect(gh.mergePr).toHaveBeenCalledTimes(1); + expect(gh.mergePr).toHaveBeenCalledWith('Ark0N/Codeman', 381); + expect(tg.last()).toContain('Merged #381'); + expect(Object.keys(bot.store.state.pending)).toHaveLength(0); + expect(tg.edits).toContain(9); // the keyboard is removed from the confirmation message + + await bot.handleUpdate(cb(data)); + expect(gh.mergePr).toHaveBeenCalledTimes(1); + expect(tg.last()).toContain('no longer valid'); + }); + + it('ignores a confirmation tap from a foreign chat', async () => { + await bot.handleUpdate(msg('/merge 381')); + await bot.handleUpdate(cb(tg.confirmData(), 2)); + expect(gh.mergePr).not.toHaveBeenCalled(); + }); + + it('refuses to offer a merge for a conflicting PR and warns about red CI', async () => { + gh.getPrDetail.mockImplementationOnce(async () => detail({ mergeable: 'CONFLICTING' })); + await bot.handleUpdate(msg('/merge 381')); + expect(tg.last()).toContain('needs a rebase'); + expect(Object.keys(bot.store.state.pending)).toHaveLength(0); + + gh.getCiStatus.mockImplementationOnce(async () => ({ state: 'failed', runs: [] })); + await bot.handleUpdate(msg('/merge 381')); + expect(tg.last()).toContain('CI is red'); + expect(Object.keys(bot.store.state.pending)).toHaveLength(1); + }); + + it('cancel drops the pending confirmation', async () => { + await bot.handleUpdate(msg('/merge 381')); + const data = tg.confirmData().replace(/^confirm:/, 'cancel:'); + await bot.handleUpdate(cb(data)); + expect(Object.keys(bot.store.state.pending)).toHaveLength(0); + await bot.handleUpdate(cb(tg.sent[tg.sent.length - 1] ? data.replace(/^cancel:/, 'confirm:') : '')); + expect(gh.mergePr).not.toHaveBeenCalled(); + }); + + it('closes with the given comment after confirmation, and asks for one when missing', async () => { + await bot.handleUpdate(msg('/close 381')); + expect(tg.last()).toContain('Reply to this message'); + expect(gh.closePr).not.toHaveBeenCalled(); + + await bot.handleUpdate(msg('/close 381 superseded by #372')); + expect(tg.last()).toContain('superseded by #372'); + await bot.handleUpdate(cb(tg.confirmData())); + expect(gh.closePr).toHaveBeenCalledWith('Ark0N/Codeman', 381, 'superseded by #372'); + }); + + it('posts the draft only after confirmation', async () => { + await bot.handleUpdate(msg('/post 381')); + expect(gh.commentPr).not.toHaveBeenCalled(); + expect(tg.sent.some((s) => s.plain && s.text === 'Thanks, merging.')).toBe(true); + await bot.handleUpdate(cb(tg.confirmData())); + expect(gh.commentPr).toHaveBeenCalledWith('Ark0N/Codeman', 381, 'Thanks, merging.'); + }); + + it('a reply to a review message becomes a follow-up, refused when nothing was reviewed', async () => { + const rec = bot.store.upsertPr(detail({ number: 390, title: 'other' })); + bot.store.rememberMessage(55, 390); + expect(rec.reviewedSha).toBeUndefined(); + await bot.handleUpdate(msg('does it handle X?', 1, { message_id: 55 })); + expect(tg.last()).toContain('No review of #390 yet'); + }); + + it('reports status with verdict icons', async () => { + await bot.handleUpdate(msg('/status')); + expect(tg.last()).toContain('✅ #381'); + }); +}); diff --git a/test/pr-bot-report.test.ts b/test/pr-bot-report.test.ts new file mode 100644 index 00000000..0981fe73 --- /dev/null +++ b/test/pr-bot-report.test.ts @@ -0,0 +1,370 @@ +/** + * @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