Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
dist
.ci-artifacts
.git
.old
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Guards the committed build output. Because dist/ is checked in (so consumers
# of the git dependency get prebuilt JS with no install-time build), it can
# drift from src/ if someone edits source without rebuilding. This job rebuilds
# and fails if the committed dist/ doesn't match.
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
dist-fresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm

- run: pnpm install --frozen-lockfile

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI installs dependencies with --frozen-lockfile, but there's no pnpm.lock file visible in the diff. If the lockfile doesn't exist, this step will fail. Either commit a lockfile or use --no-frozen-lockfile for the CI install step.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow uses --frozen-lockfile but no pnpm.lock file appears in this diff. The install step will fail unless the lockfile exists. Either commit a lockfile or change to --no-frozen-lockfile.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow uses --frozen-lockfile but no pnpm.lock file appears anywhere in this diff. The install step will fail unless a lockfile is committed to the repository. Either commit a lockfile generated by pnpm install or change to --no-frozen-lockfile.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow uses --frozen-lockfile but no pnpm.lock file appears in this diff. The install step will fail unless a lockfile is committed. Either commit a lockfile generated by pnpm install or change to --no-frozen-lockfile.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow uses --frozen-lockfile but no pnpm.lock file appears in this diff. The install step will fail unless a lockfile is committed. Either commit a lockfile generated by pnpm install or change to --no-frozen-lockfile.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI workflow uses --frozen-lockfile but no pnpm.lock file appears in this diff. The install step will fail unless a lockfile is committed. Either commit a lockfile generated by pnpm install or change to --no-frozen-lockfile.


- run: pnpm typecheck

- name: Rebuild dist and check it matches what's committed
run: |
pnpm build
if ! git diff --exit-code -- dist; then
echo "::error::dist/ is stale — run 'pnpm build' and commit the result." >&2
exit 1
fi
10 changes: 8 additions & 2 deletions .github/workflows/review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
# Full history so `git diff base...head` and rename detection work.
fetch-depth: 0
Expand All @@ -24,7 +24,13 @@ jobs:

- name: Run reviewer on this PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Default GITHUB_TOKEN can POST comments but, as a GitHub App
# (integration) token, cannot `resolveReviewThread` — so reconcile
# can't clean up addressed bot threads ("Resource not accessible by
# integration"). A user PAT can. Set REVIEWER_GH_TOKEN (fine-grained:
# Pull requests = read+write) to enable thread resolution; falls back
# to the default token (post-only) when unset.
GITHUB_TOKEN: ${{ secrets.REVIEWER_GH_TOKEN || secrets.GITHUB_TOKEN }}
LLM_URL: ${{ secrets.LLM_URL }}
LLM_KEY: ${{ secrets.LLM_KEY }}
LLM_MODEL: ${{ vars.LLM_MODEL }}
Expand Down
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,33 @@ Copy [.github/workflows/review.yml](.github/workflows/review.yml) into your repo
| Secret | `LLM_URL` | `https://<your_ip_or_domain>/v1` |
| Secret | `LLM_KEY` | `sk-…` |
| Variable | `LLM_MODEL` | `deepseek-ai/DeepSeek-V4-Flash` (optional — auto-detected if unset) |
| Secret | `REVIEWER_GH_TOKEN` | optional PAT — see below |

`GITHUB_TOKEN` is injected automatically.
`GITHUB_TOKEN` is injected automatically and is enough to **post** comments. It can't **resolve** review threads, though: the default token is a GitHub App (integration) token, and `resolveReviewThread` isn't available to integrations ("Resource not accessible by integration"). So on follow-up pushes, reconcile decides a thread is addressed but can't clean it up. To enable thread resolution, add a user **PAT** as `REVIEWER_GH_TOKEN` (fine-grained: *Pull requests → read & write*, or classic `repo`); the workflow uses it when present and falls back to `GITHUB_TOKEN` otherwise.

The workflow builds the reviewer image from source on each run and triggers on every PR open / synchronize / reopen. (A pre-built image on GHCR is planned — until then, consumers should vendor the Dockerfile or build from a tagged release.)

## Quick start (npm dependency)

For Node projects, consume the reviewer as a `devDependency` instead of building the image — the CLI runs directly (no Docker, no build step; it executes its TypeScript at runtime). Works with npm, yarn, or pnpm.
For Node projects, consume the reviewer as a `devDependency` instead of building the image — no Docker required. Works with npm, yarn, or pnpm.

Add it to the consumer project, pinned to a tag (the git form is the same for every package manager):

```json
"devDependencies": {
"@reviewer/cli": "github:bilikaz/code-reviewer#v0.1.0"
"@reviewer/cli": "github:bilikaz/code-reviewer#v0.1.1"
}
```

Then copy [examples/review.yml](examples/review.yml) into `<your-project>/.github/workflows/` and set the same `LLM_URL` / `LLM_KEY` / `LLM_MODEL` secrets as above. If this reviewer repo is **private**, the consumer's install step needs a PAT — see the comments in that file.
The package ships prebuilt JavaScript — `dist/` is committed — so the install does no build step (Node refuses to run TypeScript from inside `node_modules`, so compiled output is shipped instead). Then copy [examples/review.yml](examples/review.yml) into `<your-project>/.github/workflows/` and set the same `LLM_URL` / `LLM_KEY` / `LLM_MODEL` secrets as above. If this reviewer repo is **private**, the consumer's install step needs a PAT — see the comments in that file.

Run it locally the same way:

```bash
npx reviewer run --pr https://github.com/owner/repo/pull/123 # yarn: yarn reviewer ... | pnpm: pnpm exec reviewer ...
```

Requires Node ≥24 in the consumer (the CLI uses TS transforms at runtime).
Requires Node ≥24 in the consumer (to run the compiled CLI).

## Local usage

Expand All @@ -62,6 +63,22 @@ docker run --rm --env-file .env -v "$PWD:/workspace" -w /workspace \
reviewer run --pr https://github.com/owner/repo/pull/123
```

### Local branch review (`local://`)

Review the **current checkout's branch against a base ref** — no PR, no VCS API, no token. Instead of posting, the reviewer **echoes each action to the console** (inline comments with `file:line`, the summary, and a green/red verdict) — like a real provider's posts, just printed. Handy for a fast pre-push pass, especially pointed at a small local model while CI uses a heavier one.

```bash
docker build -t reviewer . # once (or after changing the reviewer)
docker run --rm -t --env-file .env -v "$PWD:/workspace" -w /workspace \
reviewer review --pr local://main
```

(`-t` gives a TTY so the output is colored; without it, or under `NO_COLOR`, it prints plain.)

`local://<base>` diffs `<base>...HEAD` (base defaults to `main`). Use `local://<branch-or-sha>` to pick a different base.

> **Must run inside a container.** Local review gives the LLM the `Bash`/`Read` tools against the mounted checkout; the container is the sandbox. The CLI refuses `local://` unless it detects a container (`/.dockerenv` / `/run/.containerenv`, created by the runtime — not a flag a caller can set), so it can't hand an LLM shell access to a bare host.

### Commands

| Command | What runs |
Expand All @@ -75,7 +92,8 @@ docker run --rm --env-file .env -v "$PWD:/workspace" -w /workspace \
### Flags

```
--pr <url> required (or set PR_URL env)
--pr <url> required (or set PR_URL env). Real PR URL, mock://<scenario>,
or local://<base> (review current branch vs base; Docker only)
--llm-url <url> overrides LLM_URL
--llm-key <key> overrides LLM_KEY
--llm-model <name> overrides LLM_MODEL
Expand All @@ -100,9 +118,13 @@ All config is env-only and read once at startup. See [.env.example](.env.example
```bash
pnpm install
pnpm typecheck
pnpm build # tsc -> dist/ (+ copies prompt.md / schema.json assets)
pnpm start --pr <url> # run from source (node --experimental-transform-types)
pnpm test # builds the test image, mounts node_modules, runs vitest
```

The package ships compiled JavaScript: `tsconfig.build.json` emits `src/` to `dist/`, and [scripts/copy-assets.mjs](scripts/copy-assets.mjs) copies the runtime `.md`/`.json` assets next to the compiled modules. `bin` and `files` both point at `dist/`. **`dist/` is committed** so git-dependency consumers get prebuilt JS with no install-time build; after changing `src/`, run `pnpm build` and commit the result (CI's `dist-fresh` job fails if it's stale). The Docker image is the exception — it runs the CLI from `src/` directly via the entrypoint, so it ignores `dist/` entirely.

Tests require `tests/.env` (see [tests/.env.example](tests/.env.example)) — a real LLM endpoint is needed because the E2E suite drives the full pipeline against fixture repos via the `mock://` VCS provider.

### Layout
Expand Down
118 changes: 118 additions & 0 deletions dist/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Reviewer CLI.
//
// Subcommands:
// run — fetch → reconcile (if unresolved bot threads) → review → summarize
// fetch — fetch only
// reconcile — fetch + reconcile
// review — fetch + review (skips reconcile)
// summarize — fetch + summarize (skips review and reconcile; existing
// unresolved bot threads count as unaddressed)
//
// Flags:
// --pr <url> required (or set PR_URL env). A real PR URL (github/
// gitlab/bitbucket), `mock://<scenario>` for tests, or
// `local://<base>` to review the current checkout's
// branch against <base> (default main) with no VCS —
// findings print to the console (comments + verdict)
// instead of posting. Must run inside a container.
// --llm-url <url> overrides LLM_URL
// --llm-key <key> overrides LLM_KEY
// --llm-model <name> overrides LLM_MODEL (otherwise auto-detected)
import { buildCtx } from "./ctx.js";
import { runFetch } from "./stages/fetch/index.js";
import { runReconcile } from "./stages/reconcile/index.js";
import { runReview } from "./stages/review/index.js";
import { runSummarize } from "./stages/summarize/index.js";
const FLAG_ALIASES = {
"--pr": "pr",
"--llm-url": "llmUrl",
"--llm-key": "llmKey",
"--llm-model": "llmModel",
};
function parseArgs(argv) {
const command = argv[2];
if (!command || command === "--help" || command === "-h")
usageAndExit();
const flags = {};
for (let i = 3; i < argv.length; i++) {
const a = argv[i];
const key = FLAG_ALIASES[a];
if (!key)
die(`unknown flag: ${a}`);
const val = argv[i + 1];
if (val === undefined || val.startsWith("--"))
die(`flag ${a} requires a value`);
flags[key] = val;
i++;
}
return { command, flags };
}
function usageAndExit() {
process.stderr.write("usage: reviewer <run|fetch|reconcile|review|summarize> --pr <url|local://[base]> [--llm-url <u>] [--llm-key <k>] [--llm-model <m>]\n");
process.exit(2);
}
function die(msg) {
process.stderr.write(`error: ${msg}\n`);
process.exit(2);
}
function buildOverrides(flags) {
const prUrl = flags.pr ?? process.env.PR_URL ?? "";
if (!prUrl)
die("missing --pr (or PR_URL env)");
return {
prUrl,
llmUrl: flags.llmUrl,
llmKey: flags.llmKey,
llmModel: flags.llmModel,
};
}
// ---- Pipeline orchestration --------------------------------------------
function hasUnresolvedBotThreads(state) {
return state.comments.some((c) => c.by === "bot" && c.inline && !c.resolved && !c.parentId);
}
async function runCommand(command, ctx) {
switch (command) {
case "run": {
let state = await runFetch(ctx);
if (hasUnresolvedBotThreads(state)) {
ctx.logger.info("pipeline.followup", { reason: "unresolved_bot_threads" });
state = await runReconcile(ctx, state);
}
else {
ctx.logger.info("pipeline.initial");
}
state = await runReview(ctx, state);
return runSummarize(ctx, state);
}
case "fetch": {
return runFetch(ctx);
}
case "reconcile": {
const state = await runFetch(ctx);
return runReconcile(ctx, state);
}
case "review": {
const state = await runFetch(ctx);
return runReview(ctx, state);
}
case "summarize": {
const state = await runFetch(ctx);
return runSummarize(ctx, state);
}
default:
die(`unknown command: ${command}`);
}
}
// ---- Entry --------------------------------------------------------------
const { command, flags } = parseArgs(process.argv);
const ctx = await buildCtx(buildOverrides(flags));
try {
const state = await runCommand(command, ctx);
if (state.verdict?.fatal)
process.exit(1);
}
catch (e) {
ctx.logger.error("cli.fatal", { error: e.message });
process.exit(1);
}
Loading
Loading