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
82 changes: 82 additions & 0 deletions .github/pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
version: 1

defaults:
reasoning_effort: high
max_diff_chars_per_area: 60000

areas:
- id: rust
label: "Backend (Rust / Tauri)"
paths:
- "src-tauri/**"
rubric: |
You are reviewing changes to the Rust/Tauri backend of Prism. Apply these conventions
from CLAUDE.md strictly. Treat each as a hard rule unless the diff contains an explicit
justification:

1. The GitHub OAuth token must NEVER cross to the frontend. Token load/save/delete
lives only in `src-tauri/src/auth.rs`. Frontend may only learn auth status (boolean),
never the token value.
2. Errors must use the `AppError` enum from `src-tauri/src/error.rs`. Avoid `.unwrap()`
and `.expect()` in `#[tauri::command]` handlers — they panic the host.
3. For GitHub features, prefer `Client::graphql<T>` (one query with aliases) over
multiple REST calls. New REST usage needs a justification in the diff or commit body.
4. Schema changes in `db.rs` must be additive and idempotent (use
`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN` guarded by a migration
check). Never silently drop columns.
5. New crate dependencies in `Cargo.toml` need a clear reason — flag any addition
without one.

- id: frontend
label: "Frontend (React / TypeScript)"
paths:
- "src/**"
rubric: |
You are reviewing changes to the React/TypeScript frontend of Prism. Apply these
conventions from CLAUDE.md strictly:

1. `src/lib/api.ts` is the single boundary to Rust. Every `invoke()` wrapper here must
mirror its Rust counterpart's argument and return types — flag any drift.
2. UI uses shadcn/ui primitives in `src/components/ui/` and Tailwind v4 tokens. Avoid
hand-rolled CSS that duplicates an existing primitive.
3. The app is dark-mode only (`class="dark"` is hardcoded on `<html>`). Light-mode-only
styles or branches are dead code — flag them.
4. UI state (e.g. sidebar collapsed, expanded orgs) goes to `localStorage` with the
`prism.*` prefix. User data (watched repos, tracked orgs) goes to SQLite via Tauri
commands. Mixing these layers is a smell.
5. New npm dependencies need a clear reason — flag any addition without one.

- id: ci
label: "CI / GitHub Actions"
paths:
- ".github/**"
rubric: |
You are reviewing changes to GitHub Actions / CI configuration. Apply these checks:

1. Secrets are NEVER echoed, logged, or written to artifacts. `set -x`, `echo` of an
env var holding a secret, and uploading files containing secrets are all violations.
2. Workflows triggered by `pull_request_target` execute with repo write permissions
and access secrets — flag any change from `pull_request` to `pull_request_target`
unless the diff includes explicit fork-safety review (no checkout of PR head, no
execution of PR-supplied code).
3. Third-party actions (anything not under `actions/`) should be pinned to a tagged
version or a SHA. Pure `@main` references are a supply-chain risk.
4. Permissions blocks should follow least-privilege. Flag any `permissions: write-all`
or broadening of an existing block without justification.

- id: docs
label: "Documentation"
paths:
- "docs/**"
- "*.md"
- "**/*.md"
rubric: |
You are reviewing documentation changes. Apply these checks:

1. `docs/PRD.md` is the source of truth for product scope. Changes that alter scope
or conventions must also be reflected in `CLAUDE.md` (or vice versa) — flag drift
between the two.
2. Code, identifiers, and commit messages stay in English. PT-BR is acceptable in
user-facing UI copy and in user-conversation transcripts but not in code.
3. Avoid stating things the code already says clearly. Documentation should explain
the WHY (constraints, motivations, past decisions), not narrate the WHAT.
171 changes: 171 additions & 0 deletions .github/scripts/pr_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Run Codex CLI as a per-area PR reviewer.

Reads `.github/pr-review.yml`, computes which areas the PR diff touches,
and runs one `codex exec` invocation per affected area with a scoped diff
plus the area's rubric. Aggregates findings into a single JSON file that
the workflow uses to post a PR comment.
"""
import argparse
import fnmatch
import json
import subprocess
import sys
import time
from pathlib import Path

import yaml


def changed_files(base_sha: str, head_sha: str) -> list[str]:
out = subprocess.check_output(
["git", "diff", "--name-only", f"{base_sha}...{head_sha}"],
text=True,
)
return [line for line in out.splitlines() if line.strip()]


def match_area(area: dict, files: list[str]) -> list[str]:
patterns = area.get("paths", []) or []
matched: list[str] = []
for f in files:
for pat in patterns:
if fnmatch.fnmatch(f, pat):
matched.append(f)
break
return matched


def scoped_diff(base_sha: str, head_sha: str, files: list[str]) -> str:
result = subprocess.run(
["git", "diff", f"{base_sha}...{head_sha}", "--"] + files,
capture_output=True, text=True, check=True,
)
return result.stdout


def build_prompt(area: dict, diff_text: str, files: list[str]) -> str:
file_list = "\n".join(f"- {f}" for f in files)
return f"""You are reviewing a pull request, scoped to the area: {area.get('label', area['id'])}.

Files in this area touched by the PR:
{file_list}

Apply the following rubric. Each rule is a hard requirement unless the diff
includes an explicit justification.

{area['rubric']}

Output format (plain text, no markdown headers):
- Line 1: VERDICT: PASS or VERDICT: CONCERNS
- If CONCERNS, follow with a numbered list. Each item:
N. <file_path>:<line> — <description>. Suggestion: <action>.
- Cite a specific line in the diff for every concern. Do not invent issues.
- Do not flag style or lint issues unless the rubric explicitly demands it.
- Be concise but specific. No filler.

--- DIFF (scoped to this area) ---
{diff_text}
--- END DIFF ---
"""


def run_codex(prompt: str, reasoning_effort: str, output_file: Path) -> tuple[int, str, str]:
if output_file.exists():
output_file.unlink()
cmd = [
"codex", "exec",
"--sandbox", "read-only",
"--skip-git-repo-check",
"-c", f"model_reasoning_effort={reasoning_effort}",
"--output-last-message", str(output_file),
prompt,
]
started = time.time()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
elapsed = time.time() - started
final = output_file.read_text().strip() if output_file.exists() else ""
log = ((result.stdout or "") + (result.stderr or "")).strip()
print(f" codex exec finished in {elapsed:.1f}s, rc={result.returncode}, "
f"final_len={len(final)}, log_len={len(log)}", flush=True)
return result.returncode, final, log


def parse_verdict(output: str) -> str:
for line in output.splitlines():
s = line.strip().upper()
if s.startswith("VERDICT:"):
v = s.split(":", 1)[1].strip()
if v.startswith("PASS"):
return "pass"
if v.startswith("CONCERN"):
return "concerns"
return "unknown"


def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--config", default=".github/pr-review.yml")
p.add_argument("--base-sha", required=True)
p.add_argument("--head-sha", required=True)
p.add_argument("--out", default="/tmp/pr-review-results.json")
args = p.parse_args()

config = yaml.safe_load(Path(args.config).read_text())
defaults = config.get("defaults", {}) or {}
reasoning = defaults.get("reasoning_effort", "medium")
max_diff_chars = int(defaults.get("max_diff_chars_per_area", 60_000))

files = changed_files(args.base_sha, args.head_sha)
print(f"Changed files ({len(files)}):", flush=True)
for f in files:
print(f" {f}", flush=True)

results = []
for area in config.get("areas", []) or []:
matched = match_area(area, files)
if not matched:
print(f"\n[{area['id']}] no matching files, skipping", flush=True)
results.append({
"area": area["id"],
"label": area.get("label", area["id"]),
"skipped": True,
"files": [],
"verdict": "skipped",
"output": "",
})
continue

print(f"\n[{area['id']}] {len(matched)} matching file(s):", flush=True)
for f in matched:
print(f" {f}", flush=True)

diff_text = scoped_diff(args.base_sha, args.head_sha, matched)
truncated = False
if len(diff_text) > max_diff_chars:
diff_text = diff_text[:max_diff_chars] + "\n\n[... diff truncated ...]"
truncated = True

prompt = build_prompt(area, diff_text, matched)
out_path = Path(f"/tmp/codex-final-{area['id']}.txt")
rc, final, log = run_codex(prompt, reasoning, out_path)

results.append({
"area": area["id"],
"label": area.get("label", area["id"]),
"skipped": False,
"files": matched,
"diff_truncated": truncated,
"ok": rc == 0,
"verdict": parse_verdict(final) if rc == 0 else "error",
"output": final,
"log_tail": log[-2000:] if rc != 0 else "",
})

Path(args.out).write_text(json.dumps(results, indent=2))
print(f"\nWrote {len(results)} area result(s) to {args.out}", flush=True)
return 0


if __name__ == "__main__":
sys.exit(main())
141 changes: 141 additions & 0 deletions .github/workflows/ai-pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: AI PR review

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write

jobs:
ai-review:
name: Codex review
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}

- name: Seed Codex auth from secret
env:
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
set -euo pipefail
if [ -z "${CODEX_AUTH_JSON:-}" ]; then
echo "::error::CODEX_AUTH_JSON secret is not set. Run 'codex login' locally and store the contents of ~/.codex/auth.json in the CODEX_AUTH_JSON repo secret."
exit 1
fi
mkdir -p ~/.codex
printf '%s' "$CODEX_AUTH_JSON" > ~/.codex/auth.json
chmod 600 ~/.codex/auth.json

- uses: actions/setup-node@v4
with:
node-version: 20

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install Codex CLI and pyyaml
run: |
npm install -g @openai/codex
pip install pyyaml

- name: Run AI review per area
id: review
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$BASE_SHA"
python .github/scripts/pr_review.py \
--config .github/pr-review.yml \
--base-sha "$BASE_SHA" \
--head-sha "$HEAD_SHA" \
--out /tmp/pr-review-results.json
echo "--- results ---"
cat /tmp/pr-review-results.json

- name: Post aggregated review comment
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const marker = '<!-- prism-ai-review -->';
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;

const verdictBadge = (v) => {
if (v === 'pass') return '✅ PASS';
if (v === 'concerns') return '⚠️ CONCERNS';
if (v === 'skipped') return '⏭️ skipped';
if (v === 'error') return '💥 error';
return '❓ unknown';
};

let body;
const reviewOk = '${{ steps.review.outcome }}' === 'success';

if (reviewOk && fs.existsSync('/tmp/pr-review-results.json')) {
const results = JSON.parse(fs.readFileSync('/tmp/pr-review-results.json', 'utf8'));

const lines = [marker, '## 🤖 AI PR review', ''];

lines.push('| Area | Verdict | Files |');
lines.push('| --- | --- | ---: |');
for (const r of results) {
const filesCell = r.skipped ? '—' : String(r.files.length);
lines.push(`| ${r.label} | ${verdictBadge(r.verdict)} | ${filesCell} |`);
}
lines.push('');

const reviewed = results.filter(r => !r.skipped);
if (reviewed.length === 0) {
lines.push('_No area in `pr-review.yml` matched the PR diff._');
} else {
for (const r of reviewed) {
lines.push(`### ${r.label} — ${verdictBadge(r.verdict)}`);
if (r.diff_truncated) {
lines.push('_Diff truncated for this area; review may be partial._');
}
if (r.ok) {
lines.push('');
lines.push(r.output || '_(empty response)_');
} else {
lines.push('');
lines.push('Codex execution failed. Last log lines:');
lines.push('```');
lines.push(r.log_tail || '(no log captured)');
lines.push('```');
}
lines.push('');
}
}

lines.push('---');
lines.push('_Reviewed with Codex CLI · rubrics in `.github/pr-review.yml`_');
body = lines.join('\n');
} else {
body = [
marker,
'## 🤖 AI PR review — failed',
'',
'The review script did not produce a results file. Check the workflow logs.',
].join('\n');
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
Loading