diff --git a/.github/pr-review.yml b/.github/pr-review.yml new file mode 100644 index 0000000..46d6625 --- /dev/null +++ b/.github/pr-review.yml @@ -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` (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 ``). 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. diff --git a/.github/scripts/pr_review.py b/.github/scripts/pr_review.py new file mode 100644 index 0000000..05d5749 --- /dev/null +++ b/.github/scripts/pr_review.py @@ -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. :. Suggestion: . +- 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()) diff --git a/.github/workflows/ai-pr-review.yml b/.github/workflows/ai-pr-review.yml new file mode 100644 index 0000000..9564733 --- /dev/null +++ b/.github/workflows/ai-pr-review.yml @@ -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 = ''; + 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 }); + }