From f82c569fea75f43bc018a650be52177b67d73a60 Mon Sep 17 00:00:00 2001 From: Vieronicka Date: Wed, 27 May 2026 22:40:09 +0530 Subject: [PATCH 1/4] Initialize PR Review Agent with basic functionality to fetch and display GitHub PR data. Added configuration for environment variables, package management, and project structure. Included README and documentation for setup and usage. --- .env.example | 11 + .gitignore | 5 + README.md | 102 +++++ docs/phase-1-feature.md | 299 +++++++++++++++ package-lock.json | 807 ++++++++++++++++++++++++++++++++++++++++ package.json | 24 ++ src/cli.ts | 113 ++++++ src/config.ts | 35 ++ src/github.ts | 105 ++++++ tsconfig.json | 14 + 10 files changed, 1515 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/phase-1-feature.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/github.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62c7c96 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 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 for Phase 1: Repository → Contents (Read), Pull requests (Read) +# Phase 3 (--post): also enable Pull requests (Write) +GITHUB_TOKEN=ghp_your_token_here + +# Your GitHub username (used to only review PRs you opened) +GITHUB_USERNAME=your-github-username 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..67f8071 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# PR Review Agent + +A small CLI to learn how GitHub pull requests work and, in later phases, post AI-generated reviews. + +## Phase 1 (current): GitHub only — no AI + +**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 + ``` + +### Run Phase 1 + +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 +``` + +### Project layout + +``` +pr-review-agent/ + src/ + cli.ts ← command-line arguments and printing + config.ts ← .env and repo parsing + github.ts ← all GitHub API calls + .env.example + package.json +``` + +## 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: LLM review — planned | +| [docs/phase-3-feature.md](docs/phase-3-feature.md) | Phase 3: Post review to GitHub — planned | +| [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** — Send diff to OpenAI/Azure OpenAI, print JSON review → [phase-2-feature.md](docs/phase-2-feature.md) +- **Phase 3** — `--post` to create a PR review comment on GitHub → [phase-3-feature.md](docs/phase-3-feature.md) +- **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..20f20d4 --- /dev/null +++ b/docs/phase-1-feature.md @@ -0,0 +1,299 @@ +# 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 +} +``` + +**Why:** One object carries everything Phase 2 needs without re-fetching from GitHub. + +--- + +## 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` | + +--- + +## 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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a16519b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,807 @@ +{ + "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" + }, + "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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8134d02 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "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..c841526 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Phase 1 entry point — learn the GitHub side before adding AI. + * + * Flow: + * 1. Read .env (token + your username) + * 2. Call GitHub API for the PR + * 3. Print a human-readable summary (--dry-run is default behavior for now) + */ +import { Command } from "commander"; +import { loadConfig, parseRepo } from "./config.js"; +import { + assertAuthorIsUser, + createOctokit, + fetchPrSummary, +} from "./github.js"; + +const program = new Command(); + +program + .name("pr-review") + .description("PR review agent — Phase 1: fetch and inspect GitHub PRs") + .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; + }) + .option( + "--dry-run", + "Fetch from GitHub and print summary only (default for Phase 1)", + true, + ) + .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; + allowAnyAuthor?: boolean; +}>(); + +async function main(): Promise { + const { token, username } = loadConfig(); + const repo = parseRepo(opts.repo); + const octokit = createOctokit(token); + + console.log("\n--- Phase 1: GitHub fetch ---\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, opts.dryRun); + + if (opts.dryRun) { + console.log("Dry-run complete. No AI call and no comment posted on GitHub."); + console.log("Next (Phase 2): send the diff to an LLM and print a review.\n"); + } +} + +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()}`); + + 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 — Phase 2 will need truncation before sending to an 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..b52670e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +import "dotenv/config"; + +export type RepoRef = { owner: string; repo: 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 }; +} + +/** 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..53df564 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,105 @@ +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; +}; + +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, + }; +} + +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/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/**/*"] +} From 84393377ca72c3976ba2e186978f4edcf55b9d61 Mon Sep 17 00:00:00 2001 From: Vieronicka Date: Mon, 1 Jun 2026 20:42:45 +0530 Subject: [PATCH 2/4] Add Phase 2 functionality for AI reviews using Gemini and OpenAI. Updated .env.example for LLM configuration, enhanced README with setup instructions, and implemented review generation with Zod validation. Modified CLI to support AI review mode and adjusted GitHub data fetching to include full diff text. --- .env.example | 11 ++ README.md | 92 +++++++++++-- docs/phase-1-feature.md | 84 +++++++++++- docs/phase-2-feature.md | 291 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 12 +- package.json | 3 +- src/cli.ts | 57 +++++--- src/config.ts | 54 ++++++++ src/github.ts | 2 + src/review.ts | 291 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 868 insertions(+), 29 deletions(-) create mode 100644 docs/phase-2-feature.md create mode 100644 src/review.ts diff --git a/.env.example b/.env.example index 62c7c96..27fb28f 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,14 @@ 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/README.md b/README.md index 67f8071..7b372d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small CLI to learn how GitHub pull requests work and, in later phases, post AI-generated reviews. -## Phase 1 (current): GitHub only — no AI +## Phase 1: GitHub fetch (dry-run) **Goal:** Prove your machine can read a PR the same way GitHub shows a diff. @@ -44,9 +44,11 @@ A small CLI to learn how GitHub pull requests work and, in later phases, post AI ``` GITHUB_TOKEN=... GITHUB_USERNAME=your-login + OPENAI_API_KEY=sk-... # Phase 2 + OPENAI_MODEL=gpt-4o-mini # optional ``` -### Run Phase 1 +### Run Phase 1 (no OpenAI call) Use a **real PR you opened** on a repo your token can access: @@ -66,18 +68,92 @@ To test fetching without the author check: npm run review -- --repo owner/repo --pr 1 --dry-run --allow-any-author ``` +## Phase 2 (current): AI review in terminal (Gemini or OpenAI) + +**Goal:** Send the PR diff to an LLM and print a structured review (summary, risks, suggestions). Nothing is posted to GitHub yet. + +### 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. + ### Project layout ``` pr-review-agent/ src/ - cli.ts ← command-line arguments and printing - config.ts ← .env and repo parsing - github.ts ← all GitHub API calls + cli.ts ← CLI orchestration + config.ts ← GitHub + OpenAI env + github.ts ← GitHub API + review.ts ← truncate diff, Gemini/OpenAI, Zod parse .env.example 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. @@ -89,14 +165,14 @@ This repo includes a **project-scoped** agent skill at `.cursor/skills/update-pr | [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: LLM review — planned | +| [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 — planned | | [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** — Send diff to OpenAI/Azure OpenAI, print JSON review → [phase-2-feature.md](docs/phase-2-feature.md) -- **Phase 3** — `--post` to create a PR review comment on GitHub → [phase-3-feature.md](docs/phase-3-feature.md) +- **Phase 2** — Gemini / OpenAI review in terminal → [phase-2-feature.md](docs/phase-2-feature.md) (done) +- **Phase 3** — `--post` to create a PR review comment on GitHub → [phase-3-feature.md](docs/phase-3-feature.md) (next) - **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 index 20f20d4..8e0cdec 100644 --- a/docs/phase-1-feature.md +++ b/docs/phase-1-feature.md @@ -249,11 +249,12 @@ assertAuthorIsUser(summary, expectedUsername): void baseBranch, headBranch, url, body, changedFiles, additions, deletions, files: [{ filename, status, additions, deletions, changes }], - diffCharCount: number + diffCharCount: number; + diffText: string; } ``` -**Why:** One object carries everything Phase 2 needs without re-fetching from GitHub. +**Why:** One object carries everything Phase 2 needs without re-fetching from GitHub (`diffText` added in Phase 2). --- @@ -290,6 +291,85 @@ assertAuthorIsUser(summary, expectedUsername): void --- +## 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`) 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/package-lock.json b/package-lock.json index a16519b..160bfb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@octokit/rest": "^21.1.1", "commander": "^13.1.0", - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.13.10", @@ -802,6 +803,15 @@ "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 index 8134d02..86b8fa6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dependencies": { "@octokit/rest": "^21.1.1", "commander": "^13.1.0", - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.13.10", diff --git a/src/cli.ts b/src/cli.ts index c841526..3713651 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,35 +1,39 @@ #!/usr/bin/env node /** - * Phase 1 entry point — learn the GitHub side before adding AI. + * PR review agent CLI * - * Flow: - * 1. Read .env (token + your username) - * 2. Call GitHub API for the PR - * 3. Print a human-readable summary (--dry-run is default behavior for now) + * Phase 1: --dry-run (default) — fetch PR from GitHub, print summary + * Phase 2: --review (or --no-dry-run) — send diff to Gemini or OpenAI and print AI review */ -import { Command } from "commander"; -import { loadConfig, parseRepo } from "./config.js"; +import { Command, Option } from "commander"; +import { loadConfig, loadLlmConfig, parseRepo } from "./config.js"; import { assertAuthorIsUser, createOctokit, fetchPrSummary, } from "./github.js"; +import { generateReview, printReview } from "./review.js"; const program = new Command(); program .name("pr-review") - .description("PR review agent — Phase 1: fetch and inspect GitHub PRs") + .description("PR review agent — fetch GitHub PRs and optionally run AI review") .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( - "--dry-run", - "Fetch from GitHub and print summary only (default for Phase 1)", - true, + "--review", + "Phase 2: run AI review after fetch (same as --no-dry-run)", ) .option( "--allow-any-author", @@ -41,15 +45,28 @@ const opts = program.opts<{ repo: string; pr: number; dryRun: boolean; + review?: boolean; allowAnyAuthor?: boolean; }>(); +/** Phase 2 when --review or --no-dry-run; otherwise Phase 1 (dry-run default) */ +const runAiReview = + 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); - console.log("\n--- Phase 1: GitHub fetch ---\n"); + const phaseLabel = dryRun + ? "Phase 1: GitHub fetch" + : "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`); @@ -60,12 +77,18 @@ async function main(): Promise { assertAuthorIsUser(summary, username); } - printSummary(summary, opts.dryRun); + printSummary(summary, dryRun); - if (opts.dryRun) { - console.log("Dry-run complete. No AI call and no comment posted on GitHub."); - console.log("Next (Phase 2): send the diff to an LLM and print a review.\n"); + if (dryRun) { + console.log("Dry-run complete. No LLM call and no comment posted on GitHub."); + console.log("Next: run with --review (or --no-dry-run) to generate an AI review.\n"); + return; } + + const llm = loadLlmConfig(); + console.log(`Calling ${providerLabel(llm.provider)} (${llm.model})…\n`); + const review = await generateReview(summary, llm); + printReview(review); } function printSummary( @@ -101,7 +124,7 @@ function printSummary( if (dryRun && summary.diffCharCount > 100_000) { console.log( - "\n Note: Large diff — Phase 2 will need truncation before sending to an LLM.", + "\n Note: Large diff — truncation will apply before sending to the LLM.", ); } } diff --git a/src/config.ts b/src/config.ts index b52670e..391bf7f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,14 @@ 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; @@ -23,6 +31,52 @@ export function loadConfig(): { 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); diff --git a/src/github.ts b/src/github.ts index 53df564..f3d771d 100644 --- a/src/github.ts +++ b/src/github.ts @@ -21,6 +21,7 @@ export type PrSummary = { changes: number; }>; diffCharCount: number; + diffText: string; }; export function createOctokit(token: string): Octokit { @@ -88,6 +89,7 @@ export async function fetchPrSummary( changes: f.changes, })), diffCharCount: diffText.length, + diffText, }; } diff --git a/src/review.ts b/src/review.ts new file mode 100644 index 0000000..10ab472 --- /dev/null +++ b/src/review.ts @@ -0,0 +1,291 @@ +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}`); + } + } +} + +export function printReview(review: ReviewResult): 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 (for your notes — not posted in Phase 2)"); + console.log("--------------------------------------------------------"); + for (const c of review.lineComments) { + console.log(` ${c.path}:${c.line} — ${c.body}`); + } + console.log(); + } + + console.log("Review complete. Nothing posted to GitHub (Phase 3 adds --post).\n"); +} From f38e533c72b9938d8c4a127850da98f25e7caccf Mon Sep 17 00:00:00 2001 From: Vieronicka Date: Mon, 1 Jun 2026 21:05:07 +0530 Subject: [PATCH 3/4] Implement Phase 3 functionality to post AI reviews to GitHub. Updated .env.example for new token scopes, added documentation for Phase 3 features, and enhanced CLI to support posting reviews. Introduced caching mechanism to prevent duplicate posts and improved review formatting for GitHub comments. --- .env.example | 4 +- docs/phase-3-feature.md | 128 ++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 73 +++++++++++++++++++---- src/github.ts | 23 ++++++++ src/review-cache.ts | 49 +++++++++++++++ src/review.ts | 56 ++++++++++++++++-- 6 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 docs/phase-3-feature.md create mode 100644 src/review-cache.ts diff --git a/.env.example b/.env.example index 27fb28f..bf80e8d 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ # GitHub: create a fine-grained Personal Access Token at # https://github.com/settings/tokens?type=beta -# Scopes for Phase 1: Repository → Contents (Read), Pull requests (Read) -# Phase 3 (--post): also enable Pull requests (Write) +# 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) 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/src/cli.ts b/src/cli.ts index 3713651..78ffc18 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,8 @@ * PR review agent CLI * * Phase 1: --dry-run (default) — fetch PR from GitHub, print summary - * Phase 2: --review (or --no-dry-run) — send diff to Gemini or OpenAI and print AI review + * 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"; @@ -11,14 +12,20 @@ import { assertAuthorIsUser, createOctokit, fetchPrSummary, + postPrReview, } from "./github.js"; -import { generateReview, printReview } from "./review.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 and optionally run AI 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); @@ -33,7 +40,15 @@ program ) .option( "--review", - "Phase 2: run AI review after fetch (same as --no-dry-run)", + "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", @@ -46,12 +61,16 @@ const opts = program.opts<{ pr: number; dryRun: boolean; review?: boolean; + post?: boolean; + force?: boolean; allowAnyAuthor?: boolean; }>(); -/** Phase 2 when --review or --no-dry-run; otherwise Phase 1 (dry-run default) */ +const wantsPost = opts.post === true; const runAiReview = - opts.review === true || process.argv.includes("--no-dry-run"); + wantsPost || + opts.review === true || + process.argv.includes("--no-dry-run"); const dryRun = !runAiReview; function providerLabel(provider: string): string { @@ -63,9 +82,13 @@ async function main(): Promise { const repo = parseRepo(opts.repo); const octokit = createOctokit(token); - const phaseLabel = dryRun - ? "Phase 1: GitHub fetch" - : "Phase 2: GitHub + AI review"; + 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}`); @@ -81,14 +104,41 @@ async function main(): Promise { if (dryRun) { console.log("Dry-run complete. No LLM call and no comment posted on GitHub."); - console.log("Next: run with --review (or --no-dry-run) to generate an AI review.\n"); + 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); - printReview(review); + + 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( @@ -107,6 +157,7 @@ function printSummary( ` 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):"); diff --git a/src/github.ts b/src/github.ts index f3d771d..aa2a48e 100644 --- a/src/github.ts +++ b/src/github.ts @@ -22,6 +22,8 @@ export type PrSummary = { }>; diffCharCount: number; diffText: string; + /** Latest commit on the PR head branch (required for posting reviews) */ + headCommitSha: string; }; export function createOctokit(token: string): Octokit { @@ -90,9 +92,30 @@ export async function fetchPrSummary( })), 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, 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 index 10ab472..b179f0f 100644 --- a/src/review.ts +++ b/src/review.ts @@ -250,7 +250,47 @@ export async function generateReview( } } -export function printReview(review: ReviewResult): void { +/** 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("-------"); @@ -279,13 +319,21 @@ export function printReview(review: ReviewResult): void { } if (review.lineComments && review.lineComments.length > 0) { - console.log("Line comments (for your notes — not posted in Phase 2)"); - console.log("--------------------------------------------------------"); + 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(); } - console.log("Review complete. Nothing posted to GitHub (Phase 3 adds --post).\n"); + 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", + ); + } } From 6802483e1de6a1a5ff469e2d17c5538acdc6c3bf Mon Sep 17 00:00:00 2001 From: Vieronicka Date: Mon, 1 Jun 2026 21:05:26 +0530 Subject: [PATCH 4/4] Update README to reflect completion of Phase 3: AI review posting to GitHub. Added details on new `--post` functionality, updated token permissions, and clarified project roadmap. Enhanced documentation for review deduplication and CLI usage. --- README.md | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b372d2..380e00a 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ To test fetching without the author check: npm run review -- --repo owner/repo --pr 1 --dry-run --allow-any-author ``` -## Phase 2 (current): AI review in terminal (Gemini or OpenAI) +## 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). Nothing is posted to GitHub yet. +**Goal:** Send the PR diff to an LLM and print a structured review (summary, risks, suggestions). ### Setup — Gemini (default) @@ -114,6 +114,34 @@ Also works: `--no-dry-run` (same as `--review`). 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 ``` @@ -122,8 +150,10 @@ pr-review-agent/ cli.ts ← CLI orchestration config.ts ← GitHub + OpenAI env github.ts ← GitHub API - review.ts ← truncate diff, Gemini/OpenAI, Zod parse + 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 ``` @@ -166,13 +196,13 @@ This repo includes a **project-scoped** agent skill at `.cursor/skills/update-pr | [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 — planned | +| [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 create a PR review comment on GitHub → [phase-3-feature.md](docs/phase-3-feature.md) (next) +- **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)