diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf80e8d --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy this file to .env and fill in your values. +# Never commit .env — it contains secrets. + +# GitHub: create a fine-grained Personal Access Token at +# https://github.com/settings/tokens?type=beta +# Scopes: Contents (Read), Pull requests (Read) for Phase 1–2 +# Phase 3 (--post): Pull requests → Read and write +GITHUB_TOKEN=ghp_your_token_here + +# Your GitHub username (used to only review PRs you opened) +GITHUB_USERNAME=your-github-username + +# Phase 2 — LLM provider: gemini (default) or openai +LLM_PROVIDER=gemini + +# Gemini — https://aistudio.google.com/apikey +GEMINI_API_KEY=your_gemini_key_here +GEMINI_MODEL=gemini-2.5-flash + +# OpenAI — https://platform.openai.com/api-keys (set LLM_PROVIDER=openai) +OPENAI_API_KEY=sk-your_key_here +OPENAI_MODEL=gpt-4o-mini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3eea76a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.cache/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..380e00a --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# PR Review Agent + +A small CLI to learn how GitHub pull requests work and, in later phases, post AI-generated reviews. + +## Phase 1: GitHub fetch (dry-run) + +**Goal:** Prove your machine can read a PR the same way GitHub shows a diff. + +### What you learn + +| Concept | Where in code | +|--------|----------------| +| Environment secrets | `.env` + `src/config.ts` | +| `owner/repo` slug | `parseRepo()` in `config.ts` | +| GitHub REST API | `src/github.ts` (`pulls.get`, `pulls.listFiles`) | +| Unified diff | `pulls.get` with `mediaType: diff` | +| “Only my PRs” guard | `assertAuthorIsUser()` | + +### Setup + +1. **Node.js 20+** — check with `node -v` + +2. **Install dependencies** + + ```bash + cd C:\Users\VieronickaKanesamoor\pr-review-agent + npm install + ``` + +3. **GitHub token** (fine-grained PAT) + + - Go to [GitHub → Settings → Developer settings → Fine-grained tokens](https://github.com/settings/tokens?type=beta) + - Create a token with access to **one repository** you use for practice + - Permissions: **Contents** (Read), **Pull requests** (Read) + +4. **Configure `.env`** + + ```bash + copy .env.example .env + ``` + + Edit `.env`: + + ``` + GITHUB_TOKEN=... + GITHUB_USERNAME=your-login + OPENAI_API_KEY=sk-... # Phase 2 + OPENAI_MODEL=gpt-4o-mini # optional + ``` + +### Run Phase 1 (no OpenAI call) + +Use a **real PR you opened** on a repo your token can access: + +```bash +npm run review -- --repo owner/repo --pr 1 --dry-run +``` + +Example: + +```bash +npm run review -- --repo octocat/Hello-World --pr 42 --dry-run +``` + +To test fetching without the author check: + +```bash +npm run review -- --repo owner/repo --pr 1 --dry-run --allow-any-author +``` + +## Phase 2: AI review in terminal (Gemini or OpenAI) + +**Goal:** Send the PR diff to an LLM and print a structured review (summary, risks, suggestions). + +### Setup — Gemini (default) + +1. Create an API key at [Google AI Studio](https://aistudio.google.com/apikey) +2. Add to `.env`: + ``` + LLM_PROVIDER=gemini + GEMINI_API_KEY=your_key_here + GEMINI_MODEL=gemini-2.5-flash + ``` + **Why Gemini default:** Free tier is suitable for learning; no OpenAI prepaid credits required. + + **If you get 429 `limit: 0`:** `gemini-2.0-flash` often has **no free-tier quota** anymore. Use `gemini-2.5-flash`. Some accounts also need a billing method linked in [AI Studio](https://aistudio.google.com) to activate free limits (you are not charged until you exceed free quota). + +### Setup — OpenAI (when you have API credits) + +1. Create an API key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys) +2. Add to `.env`: + ``` + LLM_PROVIDER=openai + OPENAI_API_KEY=sk-... + OPENAI_MODEL=gpt-4o-mini + ``` + **Why `gpt-4o-mini`:** Lower cost while learning; upgrade to `gpt-4o` if reviews feel shallow. + +### Run Phase 2 + +```bash +npm run review -- --repo owner/repo --pr 1 --review +``` + +Example: + +```bash +npm run review -- --repo vieronicka/PR-Review-Bot --pr 1 --review +``` + +Also works: `--no-dry-run` (same as `--review`). + +**Why `--review`:** Dry-run is the default so you do not accidentally spend API credits. Omitting flags runs Phase 1 only. + +See [docs/phase-2-feature.md](docs/phase-2-feature.md) for methods and flow. + +## Phase 3 (current): Post review to GitHub + +**Goal:** Publish the same AI review as a comment on the pull request (opt-in). + +### PAT permission (required for `--post`) + +Edit your fine-grained token → **Pull requests: Read and write** (was Read-only for Phase 1–2). + +**Why:** Creating a PR review uses `POST .../pulls/{id}/reviews`, which requires write access. + +### Run Phase 3 + +```bash +npm run review -- --repo owner/repo --pr 1 --review --post +``` + +`--post` implies `--review` (you do not need both flags, but both are fine). + +| Flag | What | +|------|------| +| `--post` | Publish summary + risks + suggestions on the PR | +| `--force` | Post again even if this commit was already reviewed | +| (no `--post`) | Terminal only (Phase 2) | + +**Deduplication:** `.cache/reviewed-shas.json` stores the last reviewed commit per PR so repeated runs do not spam the PR. Use `--force` to override. + +See [docs/phase-3-feature.md](docs/phase-3-feature.md). + +### Project layout + +``` +pr-review-agent/ + src/ + cli.ts ← CLI orchestration + config.ts ← GitHub + OpenAI env + github.ts ← GitHub API + review.ts ← truncate diff, Gemini/OpenAI, Zod parse + review-cache.ts ← dedupe by commit SHA before --post + .env.example + .cache/ ← reviewed SHAs (gitignored) + package.json +``` + +## Dependencies + +Run once after cloning (installs everything in `package.json`, including Phase 2 packages): + +```bash +npm install +``` + +You do **not** need to run `npm install zod` separately unless you are adding it yourself. + +| Package | Phase | What it does | +|---------|-------|----------------| +| `@octokit/rest` | 1 | GitHub REST API client (fetch PR, diff, files) | +| `commander` | 1 | CLI flags (`--repo`, `--pr`, `--dry-run`) | +| `dotenv` | 1 | Load secrets from `.env` | +| **`zod`** | **2** | Validate LLM JSON review before printing (see below) | +| `tsx` | dev | Run TypeScript without a separate compile step | +| `typescript` | dev | Type checking (`npm run typecheck`) | + +### What is Zod? (Phase 2) + +[Zod](https://zod.dev/) checks that the LLM response matches the shape your app expects (`summary`, `risks`, `suggestions`, optional `lineComments`). Gemini and OpenAI return text; the model can omit fields or return invalid JSON. Zod catches that in `src/review.ts` so you get a clear error (and one automatic retry) instead of broken output. + +**Why it’s a dependency:** Listed in `package.json` → installed by `npm install` → imported in `review.ts` as `import { z } from "zod"`. + +More detail: [docs/phase-2-feature.md](docs/phase-2-feature.md) (Dependencies section). + +## Cursor project skill + +This repo includes a **project-scoped** agent skill at `.cursor/skills/update-project-docs/` so Cursor keeps `README.md` and `docs/` in sync when you implement features. It applies only in this workspace, not globally. + +## Documentation + +| Doc | Description | +|-----|-------------| +| [docs/PLAN.md](docs/PLAN.md) | Full learning plan and progress checklist | +| [docs/git-update-commands.md](docs/git-update-commands.md) | First commit and push to GitHub (branch setup) | +| [docs/phase-1-feature.md](docs/phase-1-feature.md) | Phase 1: GitHub fetch — what, why, how (implemented) | +| [docs/phase-2-feature.md](docs/phase-2-feature.md) | Phase 2: Gemini / OpenAI review — implemented | +| [docs/phase-3-feature.md](docs/phase-3-feature.md) | Phase 3: Post review to GitHub — implemented | +| [docs/phase-4-feature.md](docs/phase-4-feature.md) | Phase 4: React dashboard — optional | +| [docs/phase-5-feature.md](docs/phase-5-feature.md) | Phase 5: GitHub Action — optional | + +## Roadmap + +- **Phase 2** — Gemini / OpenAI review in terminal → [phase-2-feature.md](docs/phase-2-feature.md) (done) +- **Phase 3** — `--post` to GitHub → [phase-3-feature.md](docs/phase-3-feature.md) (done) +- **Phase 4** — Optional React UI → [phase-4-feature.md](docs/phase-4-feature.md) +- **Phase 5** — GitHub Action for whole-team reviews → [phase-5-feature.md](docs/phase-5-feature.md) diff --git a/docs/phase-1-feature.md b/docs/phase-1-feature.md new file mode 100644 index 0000000..8e0cdec --- /dev/null +++ b/docs/phase-1-feature.md @@ -0,0 +1,379 @@ +# Phase 1 — GitHub fetch (no AI) + +**Status:** Implemented +**Goal:** Prove your CLI can authenticate with GitHub and read everything needed for a future AI review — without calling an LLM or posting comments. + +--- + +## What this phase does + +| Capability | Description | +|------------|-------------| +| Load secrets | Read `GITHUB_TOKEN` and `GITHUB_USERNAME` from `.env` | +| Parse repo slug | Turn `owner/repo` into API parameters | +| Fetch PR data | Title, author, branches, stats, file list, diff size | +| Author guard | Refuse PRs you did not open (unless overridden) | +| Terminal output | Print a human-readable summary (`--dry-run`) | + +**Command:** + +```bash +npm run review -- --repo owner/repo --pr 42 --dry-run +``` + +--- + +## Why this phase exists first + +1. **Isolate GitHub from AI** — If the CLI cannot fetch a PR, adding an LLM will only hide the real problem (bad token, wrong repo, missing permissions). +2. **Learn the PR data model** — You see exactly what GitHub returns before paying for tokens or posting public comments. +3. **Validate least-privilege token** — Read-only PAT is enough; you confirm scopes work before Phase 3 needs Write. +4. **Safe default** — `--dry-run` means zero side effects on GitHub and zero LLM cost. + +--- + +## How it works (end-to-end flow) + +```mermaid +sequenceDiagram + participant User + participant CLI as cli.ts + participant Config as config.ts + participant GH as github.ts + participant API as GitHub REST API + + User->>CLI: npm run review --repo o/r --pr N + CLI->>Config: loadConfig() + Config-->>CLI: token, username + CLI->>Config: parseRepo(slug) + Config-->>CLI: { owner, repo } + CLI->>GH: createOctokit(token) + CLI->>GH: fetchPrSummary(octokit, repo, N) + GH->>API: pulls.get (JSON) + GH->>API: pulls.listFiles + GH->>API: pulls.get (diff) + API-->>GH: metadata, files, diff text + GH-->>CLI: PrSummary + CLI->>GH: assertAuthorIsUser(summary, username) + CLI->>CLI: printSummary() + CLI-->>User: terminal output +``` + +--- + +## Files and responsibilities + +| File | Role | +|------|------| +| `src/cli.ts` | Parse CLI flags, orchestrate flow, print output | +| `src/config.ts` | Load env vars, parse `owner/repo` | +| `src/github.ts` | All GitHub API calls and author check | +| `.env` | Secrets (not committed) | + +--- + +## Step-by-step: what happens when you run the command + +### Step 1 — Commander parses arguments (`cli.ts`) + +**What:** Reads `--repo`, `--pr`, `--dry-run`, `--allow-any-author`. + +**Why:** Separates “user input” from “business logic” so `github.ts` stays testable and reusable in Phase 5 (GitHub Action). + +**How:** + +- `commander` validates `--pr` is a positive integer. +- `--dry-run` defaults to `true` in Phase 1 so you never accidentally call AI or post. + +--- + +### Step 2 — Load configuration (`config.ts` → `loadConfig`) + +**What:** Reads `GITHUB_TOKEN` and `GITHUB_USERNAME` from environment (via `dotenv`). + +**Why:** + +- Token proves identity to GitHub on every request. +- Username enables “only review my PRs” without extra API calls. + +**How:** + +```typescript +loadConfig(): { token: string; username: string } +``` + +- Trims whitespace from env values. +- Throws clear errors if either variable is missing. + +**Fails when:** `.env` missing or empty → immediate error before any network call. + +--- + +### Step 3 — Parse repository slug (`config.ts` → `parseRepo`) + +**What:** Converts `vieronicka/PR-Review-Bot` → `{ owner: "vieronicka", repo: "PR-Review-Bot" }`. + +**Why:** GitHub REST paths need `owner` and `repo` as separate parameters, not one string. + +**How:** + +```typescript +parseRepo(slug: string): RepoRef +``` + +- Splits on `/`, expects exactly two non-empty parts. +- Throws if format is wrong (e.g. `myrepo` without owner). + +--- + +### Step 4 — Create API client (`github.ts` → `createOctokit`) + +**What:** Builds an authenticated Octokit client. + +**Why:** Octokit wraps GitHub REST: handles base URL, auth header, pagination, and response typing. + +**How:** + +```typescript +createOctokit(token: string): Octokit +``` + +- Passes `auth: token` → sends `Authorization: Bearer ` on each request. + +--- + +### Step 5 — Fetch PR summary (`github.ts` → `fetchPrSummary`) + +**What:** Three API calls, merged into one `PrSummary` object. + +**Why each API call:** + +| API | What | Why | +|-----|------|-----| +| `GET /repos/{owner}/{repo}/pulls/{pull_number}` | PR metadata | Title, author, branches, line counts | +| `GET .../pulls/{pull_number}/files` | Per-file stats | See what changed without parsing full diff | +| `GET .../pulls/{pull_number}` with `Accept: application/vnd.github.diff` | Unified diff text | Phase 2 sends this to the LLM; Phase 1 only measures size | + +**How:** + +```typescript +fetchPrSummary(octokit, repo, prNumber): Promise +``` + +1. `octokit.pulls.get` → JSON PR object. +2. `octokit.pulls.listFiles` → up to 100 files (`per_page: 100`). +3. `octokit.pulls.get` with `mediaType: { format: "diff" }` → raw diff string. +4. Maps API fields into `PrSummary` (does not return full diff text in Phase 1 — only `diffCharCount`). + +**Note:** Large PRs with >100 files are truncated by GitHub pagination; Phase 2 may add paging. + +--- + +### Step 6 — Author guard (`github.ts` → `assertAuthorIsUser`) + +**What:** Compares PR author login to `GITHUB_USERNAME`. + +**Why:** + +- v1 is a **personal learning tool** — you review your own PRs before asking humans. +- Prevents accidentally reviewing someone else’s PR with your token. + +**How:** + +```typescript +assertAuthorIsUser(summary, expectedUsername): void +``` + +- Case-insensitive comparison. +- Throws with a clear message naming actual vs expected author. + +**Bypass:** `--allow-any-author` for testing (e.g. sample PRs in docs). + +--- + +### Step 7 — Print summary (`cli.ts` → `printSummary`) + +**What:** Formats `PrSummary` for the terminal. + +**Why:** Human-readable output before JSON/AI formatting in Phase 2. + +**How:** + +- Prints title, URL, state, author, base/head branches, stats. +- Lists each changed file with status (`added`, `modified`, `removed`, etc.). +- If `diffCharCount > 100_000`, warns that Phase 2 must truncate. + +--- + +## Methods reference + +### `src/config.ts` + +| Method | Input | Output | Purpose | +|--------|-------|--------|---------| +| `loadConfig()` | `process.env` | `{ token, username }` | Validate and return secrets | +| `parseRepo(slug)` | `"owner/repo"` | `RepoRef` | Split slug for API paths | + +### `src/github.ts` + +| Method | Input | Output | Purpose | +|--------|-------|--------|---------| +| `createOctokit(token)` | PAT string | `Octokit` | Authenticated client | +| `fetchPrSummary(octokit, repo, prNumber)` | Client + repo + PR # | `PrSummary` | Fetch metadata, files, diff size | +| `assertAuthorIsUser(summary, username)` | `PrSummary` + login | `void` (throws) | Enforce “my PRs only” | + +### `src/cli.ts` + +| Function | Purpose | +|----------|---------| +| `main()` | Orchestrate load → fetch → guard → print | +| `printSummary(summary, dryRun)` | Terminal formatting | + +--- + +## Types + +### `RepoRef` + +```typescript +{ owner: string; repo: string } +``` + +**Why:** Type-safe repo identification passed into all GitHub functions. + +### `PrSummary` + +```typescript +{ + number, title, state, author, + baseBranch, headBranch, url, body, + changedFiles, additions, deletions, + files: [{ filename, status, additions, deletions, changes }], + diffCharCount: number; + diffText: string; +} +``` + +**Why:** One object carries everything Phase 2 needs without re-fetching from GitHub (`diffText` added in Phase 2). + +--- + +## CLI flags + +| Flag | Default | What | Why | +|------|---------|------|-----| +| `--repo` | required | `owner/repo` | Target repository | +| `--pr` | required | PR number | Which pull request | +| `--dry-run` | `true` | Fetch only, no AI/post | Safe learning mode | +| `--allow-any-author` | off | Skip author check | Test on others’ PRs | + +--- + +## Setup checklist + +- [ ] Node.js 20+ +- [ ] `npm install` +- [ ] Fine-grained PAT: Contents (Read), Pull requests (Read), one repo selected +- [ ] `.env` with `GITHUB_TOKEN` and `GITHUB_USERNAME` +- [ ] Open a PR you authored in that repo +- [ ] Run: `npm run review -- --repo owner/repo --pr N --dry-run` + +--- + +## Common errors + +| Error | Cause | Fix | +|-------|-------|-----| +| Missing `GITHUB_TOKEN` | No `.env` | Copy `.env.example` → `.env` | +| 404 Not Found | Wrong repo slug or PR # | Check `owner/repo` and PR number on GitHub | +| 403 Forbidden | Token lacks permission or repo not selected | Add repo to token; enable Pull requests Read | +| Author mismatch | PR opened by someone else | Use your PR or `--allow-any-author` | + +--- + +## Example test output (terminal) + +**Command:** + +```bash +npm run review -- --repo vieronicka/PR-Review-Bot --pr 1 --dry-run +``` + +**Expected:** Exit code 0, summary printed, no AI call, no GitHub comment. + +**Sample output** (PR #1 on `vieronicka/PR-Review-Bot`, Phase 1 verified): + +``` +> pr-review-agent@0.1.0 review +> tsx src/cli.ts --repo vieronicka/PR-Review-Bot --pr 1 --dry-run + + +--- Phase 1: GitHub fetch --- + +Connecting as GitHub user: @vieronicka +Repository: vieronicka/PR-Review-Bot +PR number: 1 + +PR summary +---------- + #1 Initialize PR Review Agent with configuration and documentation + URL: https://github.com/vieronicka/PR-Review-Bot/pull/1 + State: open + Author: @vieronicka + Base: main ← merge target + Head: feat-vk-CLIvsGithubCommunication ← your branch + Stats: 10 files, +1515 / -0 lines + Diff size (chars): 49,235 + + Description (first 200 chars): + ## Summary + + - Adds Phase 1 of the PR Review Agent: a TypeScript CLI that authenticates with GitHub and fetches pull request metadata, changed files, and diff size without calling an LLM or posting c… + + Changed files: + [added ] .env.example (+11 -0) + [added ] .gitignore (+5 -0) + [added ] README.md (+102 -0) + [added ] docs/phase-1-feature.md (+299 -0) + [added ] package-lock.json (+807 -0) + [added ] package.json (+24 -0) + [added ] src/cli.ts (+113 -0) + [added ] src/config.ts (+35 -0) + [added ] src/github.ts (+105 -0) + [added ] tsconfig.json (+14 -0) +Dry-run complete. No AI call and no comment posted on GitHub. +Next (Phase 2): send the diff to an LLM and print a review. +``` + +**What this confirms:** + +| Output line | Meaning | +|-------------|---------| +| `@vieronicka` | `.env` username loaded; author guard passed | +| `Base: main` / `Head: feat-vk-...` | Branches match the open PR on GitHub | +| `10 files, +1515 / -0` | `pulls.get` stats | +| `Diff size (chars): 49,235` | Diff fetched; size only (full diff not printed in Phase 1) | +| Changed files list | From `pulls.listFiles` for commits on the PR branch | +| `Dry-run complete...` | No LLM, no `POST` review | + +**Note:** File list reflects **what is on the PR on GitHub**, not every file on your laptop. Push new commits to update the PR, then re-run the command. + +**Optional follow-up tests:** + +```bash +# Author guard (fails if PR author ≠ GITHUB_USERNAME) +npm run review -- --repo vieronicka/PR-Review-Bot --pr 1 --dry-run + +# Skip author check (testing only) +npm run review -- --repo owner/repo --pr N --dry-run --allow-any-author +``` + +--- + +## What Phase 2 will add (not in Phase 1) + +- Return or pass **full diff text** (not only `diffCharCount`) +- `src/review.ts` for LLM calls +- Remove “dry-run only” as the sole path when reviewing with AI + +See [phase-2-feature.md](./phase-2-feature.md). diff --git a/docs/phase-2-feature.md b/docs/phase-2-feature.md new file mode 100644 index 0000000..1370aee --- /dev/null +++ b/docs/phase-2-feature.md @@ -0,0 +1,291 @@ +# Phase 2 — LLM review (terminal output) + +**Status:** Implemented (Gemini + OpenAI) +**Goal:** Send the PR diff to an LLM (Gemini by default, or OpenAI), get structured JSON feedback, and print it in the terminal — still **without** posting to GitHub. + +--- + +## What this phase does + +| Capability | Description | +|------------|-------------| +| Build review prompt | System + user messages with PR context and diff | +| Truncate large diffs | Stay within model context limits (~80k chars) | +| Call LLM | Gemini `generateContent` or OpenAI Chat Completions (`fetch`) | +| Validate output | Zod schema for `summary`, `risks`, `suggestions`, optional `lineComments` | +| Print review | Formatted terminal output | +| Retry once | Re-request if JSON is malformed | + +**Command:** + +```bash +npm run review -- --repo owner/repo --pr 42 --review +``` + +Also works: `--no-dry-run` (same as `--review`). + +**Why `--review`:** `--dry-run` stays the default so GitHub-only fetch does not spend API credits. + +--- + +## Why this phase exists + +1. **Complete the agent loop locally** — You see AI quality before anything is public on GitHub. +2. **Learn prompt + structured output** — PR review is a single-shot task; no agent framework needed. +3. **Control cost** — Failed GitHub setup in Phase 1 would waste LLM tokens; Phase 2 runs only after fetch works. +4. **Iterate safely** — Tweak prompts and models without spamming PR comments. + +--- + +## How it works (flow) + +```mermaid +sequenceDiagram + participant CLI as cli.ts + participant GH as github.ts + participant Rev as review.ts + participant LLM as Gemini or OpenAI + + CLI->>GH: fetchPrSummary (+ full diff) + GH-->>CLI: PrSummary + diffText + CLI->>Rev: buildPrompt(summary, diffText) + Rev->>Rev: truncateDiff(diffText) + Rev->>LLM: chat/completions (JSON mode) + LLM-->>Rev: raw JSON string + Rev->>Rev: parseReviewJson() via Zod + Rev-->>CLI: ReviewResult + CLI->>CLI: printReview() +``` + +--- + +## Files + +| File | Role | +|------|------| +| `src/review.ts` | `truncateDiff`, `buildPrompt`, `generateReview`, `printReview` | +| `src/github.ts` | `fetchPrSummary` returns `diffText` + `diffCharCount` | +| `src/config.ts` | `loadLlmConfig()` — `LLM_PROVIDER`, Gemini or OpenAI keys | +| `src/cli.ts` | `--no-dry-run` triggers `generateReview` | + +--- + +## Methods reference + +### `src/config.ts` + +| Method | What | Why | +|--------|------|-----| +| `loadLlmConfig()` | Reads `LLM_PROVIDER` (default `gemini`) and matching API key | Switch providers without code changes | + +**Env vars (Gemini — default):** + +```env +LLM_PROVIDER=gemini +GEMINI_API_KEY=... +GEMINI_MODEL=gemini-2.0-flash +``` + +**Env vars (OpenAI):** + +```env +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4o-mini +``` + +--- + +### `src/github.ts` + +| Field | What | Why | +|-------|------|-----| +| `PrSummary.diffText` | Full unified diff string | Sent to OpenAI after truncation | + +--- + +--- + +### `src/review.ts` + +#### `truncateDiff(diffText, maxChars?)` + +**What:** Cap diff length; optionally skip lockfiles, binaries, generated paths. + +**Why:** + +- Models have context limits; huge PRs would fail or cost too much. +- `package-lock.json` diffs rarely help human-style review. + +**How:** Max 80,000 characters; split on `diff --git`; skips `package-lock.json`, `yarn.lock`, etc.; appends omission note. + +--- + +#### `buildPrompt(summary, diffText)` + +**What:** Assemble system + user messages for the chat API. + +**Why:** Consistent review focus (bugs, tests, security) across runs. + +**How:** Returns `{ system, user }` for Chat Completions — PR metadata + truncated diff in user message. + +--- + +#### `ReviewSchema` (Zod) + +**What:** Validate LLM output shape. + +**Why:** Models sometimes return markdown fences or extra fields; Zod catches that before printing. + +**Shape:** + +```typescript +{ + summary: string; // 2–4 sentences + risks: string[]; // bugs, security, breaking changes + suggestions: string[]; // actionable improvements + lineComments?: Array<{ // optional, Phase 3 may post these + path: string; + line: number; + body: string; + }>; +} +``` + +--- + +#### `callLlm(llm, system, user)` + +**What:** Dispatches to `callGemini` or `callOpenAi` based on `llm.provider`. + +**Why:** One code path for Phase 2; swap provider via `.env` only. + +#### `generateReview(summary, llm)` + +**What:** Orchestrate truncate → prompt → call → parse → retry. + +**Why:** One function for CLI and later GitHub Action to reuse. + +**How:** `truncateDiff` → `buildPrompt` → `callLlm` → Zod parse; one retry on parse failure. + +- **Gemini:** `POST generativelanguage.googleapis.com/.../generateContent` with `responseMimeType: application/json` +- **OpenAI:** `POST api.openai.com/v1/chat/completions` with `response_format: json_object` + +--- + +#### `printReview(review)` + +**What:** Format `ReviewResult` for terminal. + +**Why:** Readable output for trainees; mirrors what will later be posted to GitHub. + +--- + +### `src/cli.ts` + +| Flag / behavior | What | Why | +|-----------------|------|-----| +| `--dry-run` (default) | Phase 1 summary only | No OpenAI cost | +| `--review` or `--no-dry-run` | Calls `generateReview` + `printReview` | Phase 2 | +| Author guard | Always before LLM | Don’t spend tokens on others’ PRs | + +--- + +## Step-by-step: planned user journey + +### Step 1 — Fetch (same as Phase 1, extended) + +**What:** Get `PrSummary` + full diff text. + +**Why:** LLM cannot review code it never sees. + +--- + +### Step 2 — Truncate diff + +**What:** Reduce diff to fit context window. + +**Why:** A 5,000-line PR would exceed token limits and increase cost. + +**Fails gracefully when:** Entire diff is one huge file → summarize file list + partial diff + omission note. + +--- + +### Step 3 — Call LLM + +**What:** Send prompt; receive JSON string. + +**Why:** Structured output enables reliable parsing and Phase 3 posting. + +--- + +### Step 4 — Validate with Zod + +**What:** `ReviewSchema.parse(JSON.parse(raw))` + +**Why:** Catch hallucinated structure before showing or posting review. + +--- + +### Step 5 — Print to terminal + +**What:** Sections: Summary, Risks, Suggestions, (optional) Line comments. + +**Why:** You judge quality before `--post` in Phase 3. + +--- + +## Dependencies + +| Package | Why | +|---------|-----| +| `zod` | Runtime validation of LLM JSON | + +Uses native `fetch` (no `openai` npm package). + +--- + +## Review focus (prompt guidelines) + +**What the model should prioritize:** + +- Correctness and bugs +- Missing tests +- Error handling +- Security (secrets, injection) +- “Would I approve this?” + +**What to de-prioritize:** + +- Pure formatting/style nitpicks unless they harm readability + +**Why:** Matches trainee learning goals in [PLAN.md](./PLAN.md), not a linter replacement. + +--- + +## Pitfalls and mitigations + +| Pitfall | Mitigation | +|---------|------------| +| Diff too large | `truncateDiff`, skip lockfiles | +| Invalid JSON from model | Zod + one retry | +| High API cost | `--dry-run` for fetch-only; cap diff size | +| Wrong file content | Use unified diff from GitHub API, not local files | + +--- + +## Success criteria + +- [ ] Run with `--no-dry-run` on a small PR you opened +- [ ] Terminal shows summary, risks, and suggestions you find useful +- [x] Malformed LLM response retries once, then fails clearly (implemented) +- [x] Large diff truncated via `truncateDiff` (implemented) + +--- + +## What Phase 3 will add + +- `--post` to publish review to GitHub +- Pull requests **Write** on PAT + +See [phase-3-feature.md](./phase-3-feature.md). diff --git a/docs/phase-3-feature.md b/docs/phase-3-feature.md new file mode 100644 index 0000000..72efe1a --- /dev/null +++ b/docs/phase-3-feature.md @@ -0,0 +1,128 @@ +# Phase 3 — Post review to GitHub + +**Status:** Implemented +**Goal:** Publish the AI review on the pull request as a GitHub PR review — opt-in via `--post`. + +--- + +## What this phase does + +| Capability | Description | +|------------|-------------| +| `--post` flag | Creates a PR review on GitHub (implies AI review) | +| Summary comment | Posts `summary` + risks + suggestions as markdown body | +| Deduplication | Skips post if same `headCommitSha` already reviewed (`.cache/reviewed-shas.json`) | +| `--force` | Override deduplication and post again | +| Line comments | **Not posted** inline yet — shown in terminal only | + +**Command:** + +```bash +npm run review -- --repo owner/repo --pr 42 --review --post +``` + +`--post` alone also runs the AI review (you do not need `--review` separately). + +--- + +## Prerequisites + +### PAT permission + +| Permission | Phase 1–2 | Phase 3 | +|------------|-----------|---------| +| Pull requests | Read | **Read and write** | + +**Why Write:** `pulls.createReview` creates a review resource on the PR. + +Update your token at [GitHub fine-grained tokens](https://github.com/settings/tokens?type=beta). + +--- + +## How it works (flow) + +```mermaid +sequenceDiagram + participant CLI as cli.ts + participant Rev as review.ts + participant Cache as review-cache.ts + participant GH as github.ts + participant API as GitHub API + + CLI->>GH: fetchPrSummary (includes headCommitSha) + CLI->>Rev: generateReview() + Rev-->>CLI: ReviewResult + CLI->>Rev: printReview (terminal) + alt --post + CLI->>Cache: hasReviewedSha? + alt skip unless --force + CLI-->>CLI: skip post + else + CLI->>Rev: formatReviewBody() + CLI->>GH: postPrReview(commit_id, body) + GH->>API: POST pulls/reviews event COMMENT + CLI->>Cache: saveReviewedSha() + end + end +``` + +--- + +## Methods reference + +### `src/github.ts` + +| Method | What | Why | +|--------|------|-----| +| `PrSummary.headCommitSha` | HEAD SHA of PR branch | Required `commit_id` for review API | +| `postPrReview(octokit, repo, pr, sha, body)` | `pulls.createReview` with `event: "COMMENT"` | Official PR review | + +### `src/review.ts` + +| Method | What | Why | +|--------|------|-----| +| `formatReviewBody(review)` | Markdown for GitHub | Summary, risks, suggestions sections | +| `printReview(review, { posted, reviewUrl })` | Terminal output | Shows post URL when applicable | + +### `src/review-cache.ts` + +| Method | What | Why | +|--------|------|-----| +| `hasReviewedSha(repo, pr, sha)` | Check `.cache/reviewed-shas.json` | Avoid duplicate bot comments on same commit | +| `saveReviewedSha(repo, pr, sha)` | Write timestamp after successful post | Idempotency | + +### `src/cli.ts` + +| Flag | What | +|------|------| +| `--post` | Run AI review and publish to GitHub | +| `--force` | Post even if SHA already in cache | +| `--review` | AI only (no post) | + +--- + +## GitHub review `event` types + +| event | Used in v1? | +|-------|-------------| +| `COMMENT` | **Yes** — neutral feedback | +| `APPROVE` | No | +| `REQUEST_CHANGES` | No | + +--- + +## Success criteria + +- [ ] Upgrade PAT to Pull requests (Write) +- [ ] Run `--review --post` on your PR +- [ ] See review on GitHub PR conversation / Files tab +- [ ] Second run on same commit skips post (unless `--force`) + +--- + +## What Phase 4–5 add + +- **Phase 4:** React UI +- **Phase 5:** GitHub Action automation + +See [phase-4-feature.md](./phase-4-feature.md) and [phase-5-feature.md](./phase-5-feature.md). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..160bfb1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,817 @@ +{ + "name": "pr-review-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pr-review-agent", + "version": "0.1.0", + "dependencies": { + "@octokit/rest": "^21.1.1", + "commander": "^13.1.0", + "dotenv": "^16.4.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..86b8fa6 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "pr-review-agent", + "version": "0.1.0", + "description": "CLI that fetches GitHub PR data and (later) posts AI reviews", + "type": "module", + "scripts": { + "review": "tsx src/cli.ts", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@octokit/rest": "^21.1.1", + "commander": "^13.1.0", + "dotenv": "^16.4.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..78ffc18 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * PR review agent CLI + * + * Phase 1: --dry-run (default) — fetch PR from GitHub, print summary + * Phase 2: --review — send diff to Gemini or OpenAI and print AI review + * Phase 3: --post — publish review on GitHub (requires --review or use --post alone) + */ +import { Command, Option } from "commander"; +import { loadConfig, loadLlmConfig, parseRepo } from "./config.js"; +import { + assertAuthorIsUser, + createOctokit, + fetchPrSummary, + postPrReview, +} from "./github.js"; +import { hasReviewedSha, saveReviewedSha } from "./review-cache.js"; +import { + formatReviewBody, + generateReview, + printReview, +} from "./review.js"; + +const program = new Command(); + +program + .name("pr-review") + .description("PR review agent — fetch GitHub PRs, AI review, optional post to PR") + .requiredOption("-r, --repo ", "Repository as owner/repo") + .requiredOption("-p, --pr ", "Pull request number", (v) => { + const n = Number.parseInt(v, 10); + if (Number.isNaN(n) || n < 1) throw new Error("PR number must be a positive integer"); + return n; + }) + .addOption( + new Option( + "--dry-run", + "Fetch from GitHub and print summary only (no LLM call)", + ).default(true), + ) + .option( + "--review", + "Run AI review after fetch (same as --no-dry-run)", + ) + .option( + "--post", + "Post AI review to GitHub as a PR comment (implies --review; needs Pull requests Write on PAT)", + ) + .option( + "--force", + "Post even if this commit was already reviewed (skip deduplication cache)", + ) + .option( + "--allow-any-author", + "Skip check that you opened the PR (for testing on others' PRs)", + ); + +program.parse(); +const opts = program.opts<{ + repo: string; + pr: number; + dryRun: boolean; + review?: boolean; + post?: boolean; + force?: boolean; + allowAnyAuthor?: boolean; +}>(); + +const wantsPost = opts.post === true; +const runAiReview = + wantsPost || + opts.review === true || + process.argv.includes("--no-dry-run"); +const dryRun = !runAiReview; + +function providerLabel(provider: string): string { + return provider === "gemini" ? "Gemini" : "OpenAI"; +} + +async function main(): Promise { + const { token, username } = loadConfig(); + const repo = parseRepo(opts.repo); + const octokit = createOctokit(token); + + let phaseLabel = "Phase 1: GitHub fetch"; + if (runAiReview && wantsPost) { + phaseLabel = "Phase 3: GitHub + AI review + post"; + } else if (runAiReview) { + phaseLabel = "Phase 2: GitHub + AI review"; + } + + console.log(`\n--- ${phaseLabel} ---\n`); + console.log(`Connecting as GitHub user: @${username}`); + console.log(`Repository: ${repo.owner}/${repo.repo}`); + console.log(`PR number: ${opts.pr}\n`); + + const summary = await fetchPrSummary(octokit, repo, opts.pr); + + if (!opts.allowAnyAuthor) { + assertAuthorIsUser(summary, username); + } + + printSummary(summary, dryRun); + + if (dryRun) { + console.log("Dry-run complete. No LLM call and no comment posted on GitHub."); + console.log("Next: run with --review to generate an AI review, or --review --post to publish on the PR.\n"); + return; + } + + const llm = loadLlmConfig(); + console.log(`Calling ${providerLabel(llm.provider)} (${llm.model})…\n`); + const review = await generateReview(summary, llm); + + let posted = false; + let reviewUrl: string | undefined; + + if (wantsPost) { + if (!opts.force && hasReviewedSha(repo, opts.pr, summary.headCommitSha)) { + console.log( + `Skipping post: PR #${opts.pr} at commit ${summary.headCommitSha.slice(0, 7)} was already reviewed.`, + ); + console.log("Use --force to post again.\n"); + printReview(review); + return; + } + + const body = formatReviewBody(review); + console.log("Posting review to GitHub…\n"); + reviewUrl = await postPrReview( + octokit, + repo, + opts.pr, + summary.headCommitSha, + body, + ); + saveReviewedSha(repo, opts.pr, summary.headCommitSha); + posted = true; + } + + printReview(review, { posted, reviewUrl }); +} + +function printSummary( + summary: Awaited>, + dryRun: boolean, +): void { + console.log("PR summary"); + console.log("----------"); + console.log(` #${summary.number} ${summary.title}`); + console.log(` URL: ${summary.url}`); + console.log(` State: ${summary.state}`); + console.log(` Author: @${summary.author}`); + console.log(` Base: ${summary.baseBranch} ← merge target`); + console.log(` Head: ${summary.headBranch} ← your branch`); + console.log( + ` Stats: ${summary.changedFiles} files, +${summary.additions} / -${summary.deletions} lines`, + ); + console.log(` Diff size (chars): ${summary.diffCharCount.toLocaleString()}`); + console.log(` Head commit: ${summary.headCommitSha.slice(0, 7)}`); + + if (summary.body?.trim()) { + console.log("\n Description (first 200 chars):"); + console.log( + ` ${summary.body.trim().slice(0, 200).replace(/\n/g, "\n ")}${summary.body.length > 200 ? "…" : ""}`, + ); + } + + console.log("\n Changed files:"); + for (const f of summary.files) { + console.log( + ` [${f.status.padEnd(12)}] ${f.filename} (+${f.additions} -${f.deletions})`, + ); + } + + if (dryRun && summary.diffCharCount > 100_000) { + console.log( + "\n Note: Large diff — truncation will apply before sending to the LLM.", + ); + } +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`\nError: ${message}\n`); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..391bf7f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,89 @@ +import "dotenv/config"; + +export type RepoRef = { owner: string; repo: string }; + +export type LlmProvider = "gemini" | "openai"; + +export type LlmConfig = { + provider: LlmProvider; + apiKey: string; + model: string; +}; + +export function loadConfig(): { + token: string; + username: string; +} { + const token = process.env.GITHUB_TOKEN?.trim(); + const username = process.env.GITHUB_USERNAME?.trim(); + + if (!token) { + throw new Error( + "Missing GITHUB_TOKEN. Copy .env.example to .env and add your token.", + ); + } + if (!username) { + throw new Error( + "Missing GITHUB_USERNAME. Set your GitHub login name in .env.", + ); + } + + return { token, username }; +} + +/** + * LLM provider for Phase 2. + * Default: gemini (free tier friendly). Set LLM_PROVIDER=openai when you have OpenAI credits. + */ +export function loadLlmConfig(): LlmConfig { + const explicit = process.env.LLM_PROVIDER?.trim().toLowerCase(); + + let provider: LlmProvider; + if (explicit === "openai" || explicit === "gemini") { + provider = explicit; + } else if (explicit) { + throw new Error( + `Invalid LLM_PROVIDER "${explicit}". Use "gemini" or "openai".`, + ); + } else { + provider = "gemini"; + } + + if (provider === "gemini") { + const apiKey = + process.env.GEMINI_API_KEY?.trim() || + process.env.GOOGLE_API_KEY?.trim(); + const model = process.env.GEMINI_MODEL?.trim() || "gemini-2.5-flash"; + if (!apiKey) { + throw new Error( + "Missing GEMINI_API_KEY. Get a key at https://aistudio.google.com/apikey", + ); + } + if (!apiKey.startsWith("AIza") && !apiKey.startsWith("AQ.")) { + console.warn( + "Warning: GEMINI_API_KEY usually starts with AIzaSy (from AI Studio). Check you copied the API key, not another token.", + ); + } + return { provider: "gemini", apiKey, model }; + } + + const apiKey = process.env.OPENAI_API_KEY?.trim(); + const model = process.env.OPENAI_MODEL?.trim() || "gpt-4o-mini"; + if (!apiKey) { + throw new Error( + "Missing OPENAI_API_KEY. Add it to .env — get a key at https://platform.openai.com/api-keys", + ); + } + return { provider: "openai", apiKey, model }; +} + +/** Parse "octocat/Hello-World" into { owner, repo } */ +export function parseRepo(slug: string): RepoRef { + const parts = slug.trim().split("/").filter(Boolean); + if (parts.length !== 2) { + throw new Error( + `Invalid repo "${slug}". Use format: owner/repo (e.g. microsoft/vscode)`, + ); + } + return { owner: parts[0], repo: parts[1] }; +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..aa2a48e --- /dev/null +++ b/src/github.ts @@ -0,0 +1,130 @@ +import { Octokit } from "@octokit/rest"; +import type { RepoRef } from "./config.js"; + +export type PrSummary = { + number: number; + title: string; + state: string; + author: string; + baseBranch: string; + headBranch: string; + url: string; + body: string | null; + changedFiles: number; + additions: number; + deletions: number; + files: Array<{ + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + }>; + diffCharCount: number; + diffText: string; + /** Latest commit on the PR head branch (required for posting reviews) */ + headCommitSha: string; +}; + +export function createOctokit(token: string): Octokit { + return new Octokit({ auth: token }); +} + +/** + * Phase 1 core: talk to GitHub and pull everything we need for a future AI review. + * + * APIs used: + * - pulls.get → PR metadata (title, author, branches) + * - pulls.listFiles → per-file stats (no full patch yet in dry-run display) + * - pulls.get with mediaType diff → raw unified diff text + */ +export async function fetchPrSummary( + octokit: Octokit, + repo: RepoRef, + prNumber: number, +): Promise { + const { owner, repo: repoName } = repo; + + const { data: pr } = await octokit.pulls.get({ + owner, + repo: repoName, + pull_number: prNumber, + }); + + const { data: fileList } = await octokit.pulls.listFiles({ + owner, + repo: repoName, + pull_number: prNumber, + per_page: 100, + }); + + // Request the diff as plain text (application/vnd.github.diff) + const diffResponse = await octokit.pulls.get({ + owner, + repo: repoName, + pull_number: prNumber, + mediaType: { format: "diff" }, + }); + + const diffText = + typeof diffResponse.data === "string" + ? diffResponse.data + : JSON.stringify(diffResponse.data); + + return { + number: pr.number, + title: pr.title, + state: pr.state, + author: pr.user?.login ?? "unknown", + baseBranch: pr.base.ref, + headBranch: pr.head.ref, + url: pr.html_url, + body: pr.body, + changedFiles: pr.changed_files, + additions: pr.additions, + deletions: pr.deletions, + files: fileList.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + })), + diffCharCount: diffText.length, + diffText, + headCommitSha: pr.head.sha, + }; +} + +/** Create a PR review comment (summary only; event COMMENT = neutral feedback) */ +export async function postPrReview( + octokit: Octokit, + repo: RepoRef, + prNumber: number, + headCommitSha: string, + body: string, +): Promise { + const { data } = await octokit.pulls.createReview({ + owner: repo.owner, + repo: repo.repo, + pull_number: prNumber, + commit_id: headCommitSha, + body, + event: "COMMENT", + }); + + return data.html_url ?? `https://github.com/${repo.owner}/${repo.repo}/pull/${prNumber}#pullrequestreview-${data.id}`; +} + +export function assertAuthorIsUser( + summary: PrSummary, + expectedUsername: string, +): void { + const normalized = expectedUsername.toLowerCase(); + if (summary.author.toLowerCase() !== normalized) { + throw new Error( + `PR #${summary.number} was opened by @${summary.author}, not @${expectedUsername}. ` + + `This tool is configured to review only your own PRs.`, + ); + } +} diff --git a/src/review-cache.ts b/src/review-cache.ts new file mode 100644 index 0000000..284330e --- /dev/null +++ b/src/review-cache.ts @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { RepoRef } from "./config.js"; + +const CACHE_DIR = ".cache"; +const CACHE_FILE = join(CACHE_DIR, "reviewed-shas.json"); + +type ReviewCache = Record; + +function cacheKey(repo: RepoRef, prNumber: number, sha: string): string { + return `${repo.owner}/${repo.repo}#${prNumber}:${sha}`; +} + +function loadCache(): ReviewCache { + if (!existsSync(CACHE_FILE)) { + return {}; + } + try { + return JSON.parse(readFileSync(CACHE_FILE, "utf8")) as ReviewCache; + } catch { + return {}; + } +} + +function writeCache(cache: ReviewCache): void { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), "utf8"); +} + +/** True if we already posted a review for this PR at this commit SHA */ +export function hasReviewedSha( + repo: RepoRef, + prNumber: number, + sha: string, +): boolean { + const cache = loadCache(); + return cacheKey(repo, prNumber, sha) in cache; +} + +/** Record that a review was posted for this PR commit */ +export function saveReviewedSha( + repo: RepoRef, + prNumber: number, + sha: string, +): void { + const cache = loadCache(); + cache[cacheKey(repo, prNumber, sha)] = new Date().toISOString(); + writeCache(cache); +} diff --git a/src/review.ts b/src/review.ts new file mode 100644 index 0000000..b179f0f --- /dev/null +++ b/src/review.ts @@ -0,0 +1,339 @@ +import { z } from "zod"; +import type { LlmConfig } from "./config.js"; +import type { PrSummary } from "./github.js"; + +const ReviewSchema = z.object({ + summary: z.string(), + risks: z.array(z.string()), + suggestions: z.array(z.string()), + lineComments: z + .array( + z.object({ + path: z.string(), + line: z.number().int().positive(), + body: z.string(), + }), + ) + .optional(), +}); + +export type ReviewResult = z.infer; + +const SKIP_FILE_PATTERNS = [ + /(^|\/)package-lock\.json$/, + /(^|\/)yarn\.lock$/, + /(^|\/)pnpm-lock\.yaml$/, + /(^|\/)Cargo\.lock$/, + /\.min\.(js|css)$/, +]; + +const DEFAULT_MAX_DIFF_CHARS = 80_000; + +export type TruncateResult = { + diff: string; + omittedFileCount: number; + skippedLockfiles: number; +}; + +/** Split unified diff by file; cap total size; skip noisy lockfiles */ +export function truncateDiff( + diffText: string, + maxChars: number = DEFAULT_MAX_DIFF_CHARS, +): TruncateResult { + const parts = diffText.split(/(?=^diff --git )/m); + const header = parts[0]?.startsWith("diff --git") ? "" : (parts.shift() ?? ""); + const chunks: string[] = header ? [header] : []; + let omittedFileCount = 0; + let skippedLockfiles = 0; + let totalLen = chunks.join("").length; + + for (const part of parts) { + if (!part.startsWith("diff --git ")) continue; + + const firstLine = part.split("\n")[0] ?? ""; + const pathMatch = firstLine.match(/^diff --git a\/(.+?) b\//); + const path = pathMatch?.[1] ?? ""; + + if (SKIP_FILE_PATTERNS.some((re) => re.test(path))) { + skippedLockfiles += 1; + continue; + } + + if (totalLen + part.length > maxChars) { + omittedFileCount += 1; + continue; + } + + chunks.push(part); + totalLen += part.length; + } + + let diff = chunks.join(""); + const notes: string[] = []; + if (skippedLockfiles > 0) { + notes.push(`${skippedLockfiles} lockfile(s) skipped`); + } + if (omittedFileCount > 0) { + notes.push(`${omittedFileCount} file(s) omitted (size limit)`); + } + if (notes.length > 0) { + diff += `\n\n[Diff truncated: ${notes.join("; ")}. Max ${maxChars.toLocaleString()} chars.]\n`; + } + + return { diff, omittedFileCount, skippedLockfiles }; +} + +export function buildPrompt(summary: PrSummary, truncatedDiff: string): { + system: string; + user: string; +} { + const fileList = summary.files + .map((f) => `- ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})`) + .join("\n"); + + const system = `You are a senior software engineer performing a pull request code review. +Respond with a single JSON object only (no markdown fences), matching this shape: +{ + "summary": "2-4 sentences overall assessment", + "risks": ["bugs, security, or breaking issues"], + "suggestions": ["specific actionable improvements"], + "lineComments": [{"path": "file.ts", "line": 10, "body": "optional inline note"}] +} + +Focus on correctness, missing tests, error handling, and security (secrets, injection). +Avoid pure style nitpicks. lineComments is optional; include only high-value items.`; + + const user = `Review this pull request. + +Title: ${summary.title} +Author: @${summary.author} +Base: ${summary.baseBranch} ← Head: ${summary.headBranch} +Stats: ${summary.changedFiles} files, +${summary.additions}/-${summary.deletions} +PR description: +${summary.body?.trim() || "(none)"} + +Changed files: +${fileList} + +Unified diff: +${truncatedDiff}`; + + return { system, user }; +} + +async function callOpenAi( + apiKey: string, + model: string, + system: string, + user: string, +): Promise { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + response_format: { type: "json_object" }, + temperature: 0.3, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`OpenAI API error ${response.status}: ${body}`); + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("OpenAI returned no message content"); + } + return content; +} + +async function callGemini( + apiKey: string, + model: string, + system: string, + user: string, +): Promise { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: system }] }, + contents: [{ role: "user", parts: [{ text: user }] }], + generationConfig: { + temperature: 0.3, + responseMimeType: "application/json", + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + if (response.status === 429 && body.includes("limit: 0")) { + throw new Error( + `Gemini free-tier quota is 0 for model "${model}". ` + + `Try GEMINI_MODEL=gemini-2.5-flash in .env, or enable billing in AI Studio (can unlock free-tier limits). ` + + `Details: https://ai.google.dev/gemini-api/docs/rate-limits\n${body}`, + ); + } + throw new Error(`Gemini API error ${response.status}: ${body}`); + } + + const data = (await response.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + const content = data.candidates?.[0]?.content?.parts?.[0]?.text; + if (!content) { + throw new Error("Gemini returned no message content"); + } + return content; +} + +async function callLlm( + llm: LlmConfig, + system: string, + user: string, +): Promise { + if (llm.provider === "gemini") { + return callGemini(llm.apiKey, llm.model, system, user); + } + return callOpenAi(llm.apiKey, llm.model, system, user); +} + +function parseReviewJson(raw: string): ReviewResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("LLM response was not valid JSON"); + } + return ReviewSchema.parse(parsed); +} + +/** Fetch AI review: truncate diff → LLM → Zod validate (one retry on parse failure) */ +export async function generateReview( + summary: PrSummary, + llm: LlmConfig, +): Promise { + const { diff } = truncateDiff(summary.diffText); + const { system, user } = buildPrompt(summary, diff); + + let raw = await callLlm(llm, system, user); + + try { + return parseReviewJson(raw); + } catch (firstError) { + const retryUser = `${user}\n\nYour previous reply was invalid. Return ONLY valid JSON matching the required schema.`; + raw = await callLlm(llm, system, retryUser); + try { + return parseReviewJson(raw); + } catch { + const hint = + firstError instanceof Error ? firstError.message : String(firstError); + throw new Error(`Failed to parse LLM review after retry: ${hint}`); + } + } +} + +/** Markdown body for GitHub PR review (Phase 3) */ +export function formatReviewBody(review: ReviewResult): string { + const lines: string[] = [ + "## AI PR Review (pr-review-agent)", + "", + "### Summary", + review.summary, + "", + "### Risks", + ]; + + if (review.risks.length === 0) { + lines.push("- _(none identified)_"); + } else { + for (const r of review.risks) { + lines.push(`- ${r}`); + } + } + + lines.push("", "### Suggestions"); + if (review.suggestions.length === 0) { + lines.push("- _(none)_"); + } else { + for (const s of review.suggestions) { + lines.push(`- ${s}`); + } + } + + lines.push( + "", + "---", + "_Generated by [pr-review-agent](https://github.com) CLI — Phase 3 summary review._", + ); + + return lines.join("\n"); +} + +export function printReview( + review: ReviewResult, + options?: { posted?: boolean; reviewUrl?: string }, +): void { + console.log("\n--- AI review ---\n"); + console.log("Summary"); + console.log("-------"); + console.log(` ${review.summary}\n`); + + console.log("Risks"); + console.log("-----"); + if (review.risks.length === 0) { + console.log(" (none identified)\n"); + } else { + for (const r of review.risks) { + console.log(` • ${r}`); + } + console.log(); + } + + console.log("Suggestions"); + console.log("-----------"); + if (review.suggestions.length === 0) { + console.log(" (none)\n"); + } else { + for (const s of review.suggestions) { + console.log(` • ${s}`); + } + console.log(); + } + + if (review.lineComments && review.lineComments.length > 0) { + console.log("Line comments (terminal only — not posted as inline GitHub comments)"); + console.log("------------------------------------------------------------------"); + for (const c of review.lineComments) { + console.log(` ${c.path}:${c.line} — ${c.body}`); + } + console.log(); + } + + if (options?.posted && options.reviewUrl) { + console.log(`Posted to GitHub: ${options.reviewUrl}\n`); + } else if (options?.posted) { + console.log("Posted to GitHub.\n"); + } else { + console.log( + "Review complete (terminal only). Add --post to publish on the PR.\n", + ); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..328fa72 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "rootDir": "src", + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +}