A TypeScript CLI agent that reviews a GitHub PR with an LLM and writes real review comments, reviewer assignments, and approve/comment actions back to GitHub.
Live demo PR: yahyo7/react#1 (open this — you will see 3 inline review comments left by the agent, plus an escalation summary)
Given one PR URL and a --mode conservative|aggressive flag:
- Reads the PR (metadata, full diff, collaborators) via the GitHub API
- Sends the PR to OpenAI for analysis, getting back a structured verdict (findings + confidence + suggested reviewers)
- Applies the
--modepolicy to that verdict to decide: approve vs escalate - Writes the result to GitHub:
- approve → submits an APPROVE review with a summary
- escalate → posts a COMMENT review with inline comments on the offending lines, requests the suggested reviewers, and posts a per-reviewer comment explaining what to focus on
- Logs every LLM call (prompt, model, response, tokens, latency) to
logs/observability.jsonl
Requires Node.js 20+.
git clone <this-repo-url>
cd pr-review-agent
npm installcp .env.example .envEdit .env:
GITHUB_TOKEN=github_pat_...
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o # optional, defaults to gpt-4o-2024-08-06
GitHub token scopes (fine-grained recommended): Contents: Read, Metadata: Read, Pull requests: Read and Write.
Production note: the token should belong to a dedicated bot user, not the same user who opens PRs. GitHub blocks self-approval (422), so if the agent runs as the PR author the APPROVE event will fail.
# Aggressive mode: low approval bar (bias toward auto-approve)
npm run dev -- https://github.com/<org>/<repo>/pull/<n> --mode aggressive
# Conservative mode: high approval bar (bias toward escalate)
npm run dev -- https://github.com/<org>/<repo>/pull/<n> --mode conservative
# Production-ish: compile and run
npm run build && node dist/index.js https://github.com/<org>/<repo>/pull/<n> --mode conservativeThe agent prints the LLM verdict to stdout and progress to stderr. The actual outcome lives on GitHub — open the PR in your browser to see what landed.
The LLM's job is analysis only — it produces findings and a confidence score (0..1). The mode flag is a deterministic policy threshold applied to that output:
| Mode | Approve if |
|---|---|
aggressive |
confidence ≥ 0.5 and no critical findings |
conservative |
confidence ≥ 0.85 and no critical and no major findings |
| Otherwise | escalate to humans |
This means changing review policy is a one-line edit to two named constants in src/decision.ts — no prompt engineering required. Model and policy are decoupled.
Every LLM call appends one line to logs/observability.jsonl:
{"ts":"...","call_id":"a1b2c3d4","stage":"analyze","model":"gpt-4o-2024-08-06","prompt_messages":[...],"response":{...},"usage":{"prompt_tokens":...,"completion_tokens":...,"total_tokens":...},"latency_ms":...}Query examples (after a few runs):
# Average latency per stage
cat logs/observability.jsonl | jq -s 'group_by(.stage) | map({stage: .[0].stage, avg_ms: (map(.latency_ms) | add / length)})'
# Total tokens spent today
cat logs/observability.jsonl | jq -s 'map(.usage.total_tokens) | add'
# All findings the agent has ever made
cat logs/observability.jsonl | jq '.response.findings[]?'Plus a one-line stderr summary per call during runs:
[llm] call=a1b2 stage=analyze model=gpt-4o tokens=954→215 latency=3164ms
CLI (index.ts)
└→ github.ts read PR + collaborators
└→ analyzer.ts single OpenAI call with structured-output schema
└→ observability.ts wraps every LLM call, logs JSONL
└→ decision.ts pure function: (verdict, mode) → action
└→ writer.ts Octokit writes (createReview / requestReviewers / issues.createComment)
Single-pass pipeline, not an agent loop. The task is read → reason → write. A cyclical agent (LangGraph-style) would add failure modes (reflection drift, infinite loops) for zero benefit on this problem.
Structured outputs over raw JSON. analyzer.ts calls OpenAI with response_format: zodResponseFormat(VerdictSchema, ...), so the response is server-side-validated against the schema. No retry-on-bad-JSON loops needed.
Defense in depth on reviewers. The LLM may hallucinate a username. We pass the collaborator list into the prompt and post-filter in code against the same list — anything the model invents gets dropped before any GitHub write.
Graceful degradation. If inline review comments fail with 422 (a common cause: LLM picked a line outside the diff hunks), the writer retries with the same findings rendered into the review body. The review still lands.
See PLAN.md for the full architecture write-up with mermaid diagrams, time budget, and interview defense notes.
npm test10 unit tests on src/decision.ts (the highest-ROI test target — pure function, encodes the mode-flag policy). Boundary cases at the threshold values are explicitly covered.
✓ src/decision.test.ts (10 tests) 2ms
- Severity calibration: the LLM occasionally under-rates security issues (e.g., classifying SQL injection as
majorinstead ofcritical). Mitigation path: few-shot examples in the prompt, or a specialized security-review pass. Not done in v0.1. - Missed findings: simple bugs like missing zero-division checks may slip through. Same mitigation: targeted few-shot calibration.
- Single LLM call: very large PRs (>100k tokens of input) trigger a soft warning but still go through as one call. A map-reduce chunker would help here; intentionally deferred (YAGNI on the tested PR sizes).
- No retry on transient OpenAI/GitHub failures beyond what the SDKs do by default. Production deployments would add a wrapping retry with jitter.
- Claude Code (Sonnet 4.6 → Opus 4.7) for code generation, structured planning (
PLAN.md), and incremental refactors. All architectural decisions were authored, reviewed, and defended by the developer — Claude was a fast typist and a sounding board, not the architect.