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
15 changes: 15 additions & 0 deletions .reviewd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Project-specific review instructions (merged with global instructions)
# instructions: |
# Python 3.12+, Django 5.x.
# Check for missing select_related/prefetch_related.

# Commands to run in the PR worktree before reviewing
# test_commands:
# - uv run ruff check .
# - uv run pytest tests/ -x -q

# Which severities get inline comments (vs summary only)
inline_comments_for: [critical]

# Skip posting findings of these severities entirely
# skip_severities: [nitpick]
117 changes: 101 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- **Reuses what you already have** — your local git repos, your Claude/Gemini CLI subscription, your existing credentials. Nothing new to install or pay for.
- **Full codebase context** — reviews run on your actual local repos, not shallow CI clones. The AI can read any file, follow imports, and understand the full picture.
- **Fast via git worktrees** — isolated checkouts that share `.git`. No re-cloning. Reviews start in milliseconds.
- **Parallel reviews** — concurrent PR processing with configurable concurrency. Per-repo git locks, thread-safe SQLite, graceful shutdown.
- **Runs real commands** — configure linters, type checkers, and test suites to run during review. Failures are included in the AI's analysis.
- **Structured output** — severity-tagged findings with inline comments on specific lines and a summary comment.
- **Daemon or one-shot** — background polling across all repos, or single PR reviews on demand. Dry-run mode to preview.
Expand All @@ -28,6 +29,7 @@
- **Critical tasks** — optionally creates a BitBucket PR task on critical findings to block merge.
- **Spam protection** — configurable diff size thresholds, cooldowns, and title/author skip patterns.
- **Auto-sync config** — automatically pulls `.reviewd.yaml` from remote when the working copy is clean.
- **VPS / headless ready** — runs as a systemd service, no TTY needed. Non-interactive git, graceful shutdown, PID lock, XDG paths, env var substitution for secrets.

## Quick Start

Expand All @@ -48,19 +50,20 @@ Requires Python 3.12+. You also need `claude` or `gemini` CLI installed and auth
### 2. Configure

```bash
reviewd init # set up global config + per-project .reviewd.yaml
reviewd init # interactive wizard — detects repos, guides token creation, writes config
```

The wizard scans your repos, detects GitHub/BitBucket remotes, validates credentials, and writes both global and per-project configs. Prefer YAML? Choose "Sample config file" to get an annotated template instead.

<details>
<summary><b>GitHub setup</b></summary>

1. Create a [Personal Access Token](https://github.com/settings/tokens) with the **`repo`** scope.
2. Export it: `export GITHUB_TOKEN=ghp_...`
3. Config:
1. Create a [Fine-grained Personal Access Token](https://github.com/settings/personal-access-tokens/new) with **Pull requests: Read & Write**.
2. Config:

```yaml
github:
token: ${GITHUB_TOKEN}
token: ghp_YOUR_TOKEN

repos:
- name: my-repo
Expand All @@ -74,24 +77,24 @@ repos:
<details>
<summary><b>BitBucket setup</b></summary>

1. Create an [App Password](https://bitbucket.org/account/settings/app-passwords/) with **Pull requests: Read** and **Write**.
2. Export it: `export BB_AUTH_TOKEN=ATCTT3x...`
3. Config:
1. Create an [API token with scopes](https://id.atlassian.com/manage-profile/security/api-tokens) — select app: Bitbucket, scopes: `read:pullrequest:bitbucket`, `write:pullrequest:bitbucket`, `read:repository:bitbucket`.
2. Config (format is `email:token`):

```yaml
bitbucket:
your-workspace: ${BB_AUTH_TOKEN}
your-workspace: you@example.com:ATATT3x...

repos:
- name: my-project
path: ~/repos/my-project
provider: bitbucket
workspace: your-workspace
repo_slug: repo-slug
```

</details>

Both providers can be used in the same config.
Both providers can be used in the same config. Tokens support `${ENV_VAR}` substitution.

### 3. Review

Expand Down Expand Up @@ -119,18 +122,19 @@ Poll API → Check State (SQLite) → Fetch & Worktree → AI Review (Claude/Gem

```yaml
poll_interval_seconds: 60
max_concurrent_reviews: 4

github:
token: ${GITHUB_TOKEN}

bitbucket:
your-workspace: ${BB_AUTH_TOKEN}
other-workspace: ${OTHER_BB_TOKEN}
your-workspace: you@example.com:${BB_API_TOKEN}
other-workspace: other@example.com:${OTHER_BB_TOKEN}

cli: claude # or "gemini"
# model: claude-sonnet-4-5-20250514

# review_title: "Code Review by Nea' ~~Caisă~~ Claudiu"
# review_title: "review'd by {cli}"
# footer: "Automated review by ..."
# skip_title_patterns: ['[no-review]', '[wip]', '[no-claudiu]']
# skip_authors: []
Expand Down Expand Up @@ -207,11 +211,13 @@ All gates must pass — if any one blocks, the PR is not approved. The `rules` f
## CLI Reference

```bash
reviewd init # set up global + project config
reviewd init # interactive setup wizard
reviewd init --sample # write sample config (non-interactive)
reviewd ls # list repos and open PRs
reviewd watch -v # daemon mode
reviewd watch -v --dry-run # preview, no posting
reviewd watch -v --review-existing # review not-yet-reviewed open PRs
reviewd watch --concurrency 8 # override max concurrent reviews
reviewd pr <repo> <id> # one-shot review
reviewd pr <repo> <id> --force # re-review (bypasses draft/skip)
reviewd status <repo> # review history
Expand All @@ -223,7 +229,7 @@ reviewd status <repo> # review history
- **Git worktrees** — near-instant isolated checkouts
- **Full AI tool access** — the AI reads files, runs commands, explores code
- **JSON schema** — structured findings, the tool just parses and posts
- **SQLite state** — tracks `(repo, pr_id, commit)` to avoid duplicates
- **SQLite state** — WAL mode, thread-safe, tracks `(repo, pr_id, commit)` to avoid duplicates
- **Provider abstraction** — GitHub and BitBucket, extensible

## Security
Expand All @@ -246,9 +252,88 @@ reviewd status <repo> # review history
- Per-project config (`.reviewd.yaml`) is read from the main repo, not the worktree — PR authors can't inject instructions
- `test_commands` come only from the repo owner's config, not from PR content

## Headless / VPS Deployment

reviewd runs fully headless — no TTY, no interactive prompts in the daemon path. Deploy it on a VPS alongside your AI CLI and forget about it.

### Quick setup

```bash
# 1. Install
pip install reviewd

# 2. Write sample config (non-interactive, no wizard)
reviewd init --sample

# 3. Edit config — add tokens, repos, paths
vim ~/.config/reviewd/config.yaml

# 4. Clone repos with deploy keys
git clone git@github.com:org/repo.git ~/repos/repo

# 5. Run as daemon
reviewd watch -v
```

### What makes it VPS-ready

- **`reviewd init --sample`** — writes an annotated config template without prompts. No TTY required.
- **`GIT_TERMINAL_PROMPT=0`** on all git operations — if SSH keys or credentials aren't set up, git fails fast instead of hanging waiting for a password.
- **`-v` flag** — disables the terminal status line (carriage returns, ANSI escape codes). Output becomes clean newline-separated log lines, suitable for journald or any log collector.
- **Signal handling** — SIGTERM/SIGINT trigger graceful shutdown: in-progress reviews finish, worktrees are cleaned up, state DB is closed. Works with systemd `Type=simple`.
- **PID lock** — prevents duplicate instances (`~/.local/share/reviewd/reviewd.pid`).
- **XDG paths** — config, state, and cache directories respect `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`. Deploy to any user/path.
- **`${ENV_VAR}` substitution** in config — keep tokens in environment variables or secrets managers instead of plaintext YAML.
- **Per-project config auto-pulls** — `.reviewd.yaml` is re-read on every review cycle and auto-pulled from remote if the working copy is clean. Push config changes and they take effect without restarting.
- **Claude `--print` works headless** — no TTY needed, reads prompt from stdin, writes to stdout/stderr.
- **Gemini `--approval-mode yolo -e none`** — no approval prompts, no extensions, fully non-interactive.

### systemd service example

```ini
[Unit]
Description=reviewd — AI code review daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=reviewd
ExecStart=/usr/local/bin/reviewd watch -v
Restart=on-failure
RestartSec=30
Environment=XDG_CONFIG_HOME=/home/reviewd/.config
Environment=XDG_DATA_HOME=/home/reviewd/.local/share

[Install]
WantedBy=multi-user.target
```

### Deploy key setup

```bash
# Generate a deploy key per repo
ssh-keygen -t ed25519 -f ~/.ssh/repo_deploy_key -N ""

# Add public key to GitHub/BitBucket as a deploy key (read-only is fine)
# Configure SSH to use it
cat >> ~/.ssh/config <<EOF
Host github.com
IdentityFile ~/.ssh/repo_deploy_key
IdentitiesOnly yes
EOF

# Test non-interactive access
GIT_TERMINAL_PROMPT=0 git fetch origin
```

### Global config changes require restart

The global config (`~/.config/reviewd/config.yaml`) is loaded once at startup. If you change poll interval, add repos, or rotate tokens, restart the service. Per-project `.reviewd.yaml` files are hot-reloaded on every review cycle.

## Roadmap

- [ ] Parallel PR review queue — currently PRs are reviewed sequentially, which is fine for most teams since each review takes 1-3 minutes and the poll loop catches up quickly
- [x] Parallel PR review queue
- [ ] GitLab support

## Disclaimer
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "reviewd"
version = "0.2.3"
version = "0.3.0"
description = "Local AI code reviewer for GitHub and BitBucket PRs — uses Claude or Gemini CLI to review pull requests and post structured comments"
readme = "README.md"
license = "MIT"
Expand All @@ -19,6 +19,7 @@ dependencies = [
"click>=8.1,<9",
"httpx>=0.27,<1",
"pyyaml>=6.0,<7",
"questionary>=2.0,<3",
]

[project.scripts]
Expand Down
82 changes: 32 additions & 50 deletions src/reviewd/cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from __future__ import annotations

import importlib.metadata
import importlib.resources
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -70,14 +67,21 @@ def _setup_logging(verbose: bool):
logging.getLogger('httpcore').setLevel(logging.WARNING)


@click.group()
@click.group(invoke_without_command=True)
@click.option('--config', 'config_path', default=None, help='Path to global config file')
@click.pass_context
def main(ctx, config_path: str | None):
ctx.ensure_object(dict)
ctx.obj['config_path'] = config_path
click.echo(f'reviewd v{VERSION}')

if ctx.invoked_subcommand is None:
path = Path(config_path).expanduser() if config_path else CONFIG_PATH
if not path.exists():
ctx.invoke(init)
else:
click.echo(ctx.get_help())


UPDATE_CHECK_CACHE = Path(os.environ.get('XDG_CACHE_HOME', '~/.cache')).expanduser() / 'reviewd' / 'latest_version'
UPDATE_CHECK_INTERVAL = 6 * 3600 # seconds
Expand Down Expand Up @@ -121,75 +125,53 @@ def _check_for_updates():
def _ensure_global_config(config_path: str | None) -> Path:
path = Path(config_path).expanduser() if config_path else CONFIG_PATH
if not path.exists():
click.echo(f'No global config found at {path}.')
if click.confirm('Run init to create it?', default=True):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
example = importlib.resources.files('reviewd').joinpath('config.example.yaml')
shutil.copy2(str(example), path)
click.echo(f'Created {path}')
click.echo('Edit it to add your provider credentials and repos.')
raise SystemExit(1)
return path
from reviewd.wizard import run_wizard


def _git_repo_root() -> Path | None:
try:
result = subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
except (subprocess.CalledProcessError, FileNotFoundError):
return None
click.echo(f'No config found at {path}. Starting setup wizard...')
run_wizard()
if not path.exists():
raise SystemExit(1)
return path


@main.command()
@click.option('--sample', is_flag=True, help='Write annotated sample config (non-interactive, for VPS/CI)')
@click.pass_context
def init(ctx):
"""Set up global config and per-project .reviewd.yaml."""
# Global config
if CONFIG_PATH.exists():
click.echo(f'Global config already exists at {CONFIG_PATH}. \u2713')
else:
click.echo(f'No global config found. Creating {CONFIG_PATH}...')
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
example = importlib.resources.files('reviewd').joinpath('config.example.yaml')
shutil.copy2(str(example), CONFIG_PATH)
click.echo('Edit it to add your provider credentials and repos.')
def init(ctx, sample: bool):
"""Interactive setup wizard — configure repos, credentials, and AI CLI."""
from reviewd.wizard import SAMPLE_CONFIG, run_wizard

# Project config
repo_root = _git_repo_root()
if repo_root is None:
return

project_config = repo_root / '.reviewd.yaml'
if project_config.exists():
click.echo(f'Project config already exists at {project_config}. \u2713')
if sample:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(SAMPLE_CONFIG)
click.echo(f'Created sample config at {CONFIG_PATH}')
click.echo('Edit it to add your tokens and repos.')
return

if not click.confirm(f'Detected git repo at {repo_root}. Create .reviewd.yaml?', default=True):
return
if CONFIG_PATH.exists():
click.echo(f'Global config already exists at {CONFIG_PATH}. \u2713')
if not click.confirm('Re-run setup wizard?', default=False):
return

example = importlib.resources.files('reviewd').joinpath('project.example.yaml')
shutil.copy2(str(example), project_config)
click.echo(f'Created {project_config}')
run_wizard()


@main.command()
@click.option('-v', '--verbose', is_flag=True, help='Enable verbose logging')
@click.option('--dry-run', is_flag=True, help='Print reviews without posting')
@click.option('--review-existing', is_flag=True, help='Review unreviewed open PRs on startup')
@click.option('--cli', type=click.Choice(['claude', 'gemini']), default=None, help='Override AI CLI for all repos')
@click.option('--concurrency', type=int, default=None, help='Max concurrent reviews (default: 4)')
@click.pass_context
def watch(ctx, verbose: bool, dry_run: bool, review_existing: bool, cli: str | None):
def watch(ctx, verbose: bool, dry_run: bool, review_existing: bool, cli: str | None, concurrency: int | None):
"""Start the daemon — polls for new PRs and reviews them."""
_setup_logging(verbose)
_check_for_updates()
_ensure_global_config(ctx.obj['config_path'])
config = load_global_config(ctx.obj['config_path'])
_apply_cli_override(config, cli)
if concurrency is not None:
config.max_concurrent_reviews = concurrency
run_poll_loop(config, dry_run=dry_run, review_existing=review_existing, verbose=verbose)


Expand Down
Loading