
Review Prs
- 6 installs
- 3.5k repo stars
- Updated August 5, 2026
- brave/brave-core
review-prs is a Claude Code skill that scans recent pull requests in a configured repository for best-practice violations and posts capped inline review comments.
About
Scans recent pull requests in a configured repository for violations of documented best practices. It runs a file-based pipeline where prepare and collect scripts do the heavy fetching with zero LLM tokens and subagents review each diff, then posts capped inline review comments. It offers an interactive mode that asks for approval and an auto mode for cron. A developer or bot uses it to enforce best practices across a PR queue.
- Scans open PRs for documented best-practice violations
- File-based pipeline pushes heavy data through scripts, not the LLM
- Interactive approval mode plus an auto mode for cron/headless runs
Review Prs by the numbers
- 6 all-time installs (skills.sh)
- Ranked #870 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
review-prs capabilities & compatibility
Free; requires gh CLI and python3 for the pipeline scripts.
- Capabilities
- code review · ci cd
- Works with
- github
- Use cases
- code review · ci cd
- Runs
- Runs locally
- Pricing
- Free
What review-prs says it does
Scan recent open PRs in the configured PR repository for violations of documented best practices.
This skill only reviews PRs against existing best practices. It must NEVER create, modify, or add new best practice rules or documentation during a review run.
npx skills add https://github.com/brave/brave-core --skill review-prsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 3.5k |
| Last updated | August 5, 2026 |
| Repository | brave/brave-core ↗ |
What it does
Scan recent PRs in a repository for best-practice violations and post capped inline review comments.
Who is it for?
Teams or bots enforcing documented best practices across a queue of open PRs, including cron runs.
Skip if: Creating or modifying best-practice rules, which it must never do during a review run.
When should I use this skill?
You want to review recent PRs against existing best practices, interactively or via cron.
What you get
Violations are validated against source and posted as deduplicated, capped inline review comments.
By the numbers
- 3-stage file-based pipeline (prepare, subagents, collect)
- caps 5 violations per PR
Files
Review PRs for Best Practices
Scan recent open PRs in the configured PR repository for violations of documented best practices.
- Interactive mode (default): drafts comments and asks for user approval
before posting.
- Auto mode (
autoargument): posts all violations automatically without
approval. Designed for cron/headless use.
IMPORTANT: This skill only reviews PRs against existing best practices. It must NEVER create, modify, or add new best practice rules or documentation during a review run.
---
Architecture: File-Based Pipeline
The review pipeline minimizes LLM token usage by pushing all heavy data through files, not context:
1. prepare-review.py (zero LLM tokens) — fetches PRs, diffs, comments; writes subagent prompt files to a temp work directory; outputs a tiny JSON pointer to the work dir 2. Subagents (subagent tokens only) — each reads its prompt from a file, reviews the diff, validates violations by reading source code, writes results to a JSON file 3. collect-results.py (zero LLM tokens) — reads all result files, feeds to post-review.py which handles prioritization, dedup, posting, approval, and notifications
The main LLM session only orchestrates: run scripts, read a small manifest, launch subagents with tiny prompts, run the collector. It never sees diffs, rule text, or violation details.
---
The Job
When invoked with /review-prs [days|page<N>|#<PR>] [open|closed|all] [auto] [reviewer-priority]:
Step 1: Prepare (zero LLM tokens)
Run the prepare script with all arguments:
SKILL_DIR="<absolute path to .claude/skills/review-prs>"
python3 $SKILL_DIR/prepare-review.py [days|page<N>|#<PR>] [open|closed|all] [--auto] [--reviewer-priority]The script's stdout is a tiny JSON with work_dir and manifest paths. Progress and cost summary go to stderr.
Parse the stdout JSON to get work_dir.
Step 2: Read manifest
Read the manifest file at {work_dir}/manifest.json. It contains:
- `auto_mode`: whether to post without approval
- `bot_username`: the bot's GitHub username
- `pr_repo`: the target PR repository
- `fetch_summary`: stats on how many PRs were fetched/filtered/skipped
- `progress_lines`: pre-formatted progress messages — print these to stdout
for cron logs
- `prs`: array of PRs to review, each containing:
number,title,headRefOid,author,hasApprovalsubagent_prompts: array of entries withprompt_fileandresults_file
paths (NOT prompt text)
- `cached_prs`: already-reviewed PRs (handled by prepare script — just log
results)
- `errors`: per-PR errors encountered during preparation
Print the progress_lines. Log any errors.
For each cached PR, log:
- If
approvedis true:
APPROVE: [PR #N](url) (title) - all threads resolved, approved
- If
thread_resolution.unresolved_bot_threads > 0:
CACHED: [PR #N](url) (title) - N threads still unresolved
If no PRs to review (empty prs array), skip to Step 4.
Step 3: Launch subagents
For every PR in prs, for every entry in that PR's subagent_prompts, launch a Task subagent (subagent_type: "general-purpose") with this prompt:
Read your full review instructions from: {prompt_file}
Execute them completely. The instructions contain the PR diff, best practice rules, review rules, and validation requirements.
After reviewing and validating, write your results JSON to the file path specified in the instructions.Launch ALL subagents across ALL PRs in a single message so they run concurrently.
CRITICAL: Launch ALL subagents — no exceptions. The prepare script already filtered documents by file type. Every entry in subagent_prompts MUST get a subagent. Do NOT skip any.
Wait for all subagents to return.
CRITICAL: NEVER post reviews, comments, or approvals to GitHub yourself. Do NOT use gh api, gh pr review, gh pr comment, or any GitHub API calls to post anything on any PR. All posting is handled exclusively by collect-results.py → post-review.py in Step 4. If you post reviews directly, it creates duplicates.
Step 4: Collect and post (zero LLM tokens)
Run the collector script — it reads all subagent result files and runs post-review.py:
python3 $SKILL_DIR/collect-results.py --work-dir "$WORK_DIR" [--auto]Pass --auto if auto_mode is true.
The script handles everything: collecting violations from result files, prioritization/capping (5 per PR), rule link validation, deduplication, posting inline reviews, approval for clean PRs, cache updates, and the final summary block.
For interactive mode (no --auto): instead of running collect-results.py directly, read the result files yourself from {work_dir}/pr_{number}/{chunk_id}_results.json, present each violation to the user for approval, then write only approved violations back and run collect-results.py.
Read the script's stderr — it contains the summary. Print it for cron logs.
Summary of what consumes LLM tokens
| Phase | Token cost | Who does it |
|---|---|---|
| Fetch PRs, diffs, comments | Zero | prepare-review.py |
| Build subagent prompts (files) | Zero | prepare-review.py |
| Resolve threads, check approval | Zero | prepare-review.py |
| Read manifest, launch subagents | ~50 tokens per subagent | Main LLM session |
| Rule-checking + validation | Subagent tokens | Subagents (read prompt from file) |
| Collect results, post, approve | Zero | collect-results.py + post-review.py |
---
PR Link Format
When displaying PR numbers, ALWAYS use a full markdown link: [PR #<number>](https://github.com/$PR_REPO/pull/<number>). NEVER use bare #<number> — the TUI auto-links them against the wrong repository.
---
Closed/Merged PR Workflow
When reviewing closed or merged PRs and a violation is found:
1. Present the finding to the user (draft comment + ask for approval) 2. If approved, try to post inline review comments. If the API fails, fall back to:
gh pr comment --repo $PR_REPO {number} --body "[file:line] comment text"3. Create a follow-up issue in $PR_REPO to track the fix:
gh issue create --repo $PR_REPO --title "Fix: <brief description>" --body "Found during post-merge review of PR #<NUMBER>. <description>"4. Reference the new issue back in the PR comment.
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Chunk best-practice documents into groups of ~N rules for parallel review.
Each best-practice document contains rules marked with <a id="XX-NNN"></a>
anchors followed by ## headings. This script splits documents at ## heading
boundaries and groups them into chunks of approximately CHUNK_SIZE rules.
Rules with ### sub-headings are kept together with their parent ## section.
Usage:
python3 chunk-best-practices.py <doc_path> [--chunk-size N]
Output (JSON):
[
{
"doc": "coding-standards.md",
"chunk_index": 0,
"total_chunks": 3,
"rule_count": 20,
"headings": ["Always Include What You Use (IWYU)", ...],
"content": "# C++ Coding Standards\\n\\n<a id=\\"CS-001\\">..."
},
...
]
"""
import argparse
import json
import os
import re
import sys
DEFAULT_CHUNK_SIZE = 10
def split_into_rules(text):
"""Split a document into its header and individual ## rule sections.
Returns (header_text, list_of_rule_dicts) where each rule dict has:
- text: the full text of the rule section
- heading: the ## heading text (stripped of # and emoji)
"""
lines = text.split("\n")
# Find positions of top-level ## headings (not ### or deeper).
# Each rule boundary starts at the <a id> tag preceding the ## heading.
rule_starts = []
for i, line in enumerate(lines):
if not re.match(r"^## [^#]", line):
continue
# Walk backwards to find the start of this rule's block:
# the <a id> tag, and optionally a --- separator before it.
start = i
for j in range(max(0, i - 4), i):
if re.match(r'^<a id="', lines[j]):
start = j
break
# Include a preceding --- separator and blank lines if present.
while start > 0 and lines[start - 1].strip() in ("---", ""):
start -= 1
rule_starts.append(start)
if not rule_starts:
return text, []
header = "\n".join(lines[:rule_starts[0]])
rules = []
for i, start in enumerate(rule_starts):
end = rule_starts[i + 1] if i + 1 < len(rule_starts) else len(lines)
rule_text = "\n".join(lines[start:end])
# Extract the heading text for metadata.
heading = ""
for line in lines[start:end]:
if re.match(r"^## [^#]", line):
heading = re.sub(r"^#+\s*", "", line)
# Strip common emoji prefixes.
heading = re.sub(r"^[✅❌🔧]\s*", "", heading).strip()
break
rules.append({"text": rule_text, "heading": heading})
return header, rules
def chunk_rules(rules, chunk_size=DEFAULT_CHUNK_SIZE):
"""Group rules into evenly-sized chunks of approximately chunk_size.
Uses round() to decide the number of chunks, then distributes rules
as evenly as possible. This avoids creating oversized or undersized
chunks at the boundary.
"""
n = len(rules)
if n <= chunk_size:
return [rules]
num_chunks = max(1, round(n / chunk_size))
# Distribute evenly: some chunks get one extra rule.
base_size = n // num_chunks
extra = n % num_chunks
chunks = []
start = 0
for i in range(num_chunks):
size = base_size + (1 if i < extra else 0)
chunks.append(rules[start:start + size])
start += size
return chunks
def process_doc(doc_path, chunk_size=DEFAULT_CHUNK_SIZE):
"""Process a best-practice document and return chunks as dicts."""
with open(doc_path) as f:
text = f.read()
header, rules = split_into_rules(text)
if not rules:
return [{
"doc": os.path.basename(doc_path),
"chunk_index": 0,
"total_chunks": 1,
"rule_count": 0,
"headings": [],
"content": text,
}]
chunks = chunk_rules(rules, chunk_size)
result = []
for i, chunk in enumerate(chunks):
chunk_content = (header.rstrip("\n") + "\n\n" +
"\n".join(r["text"] for r in chunk))
headings = [r["heading"] for r in chunk]
result.append({
"doc": os.path.basename(doc_path),
"chunk_index": i,
"total_chunks": len(chunks),
"rule_count": len(chunk),
"headings": headings,
"content": chunk_content,
})
return result
def main():
parser = argparse.ArgumentParser(
description="Chunk best-practice documents for parallel review")
parser.add_argument("doc_path", help="Path to the best-practice document")
parser.add_argument(
"--chunk-size",
type=int,
default=DEFAULT_CHUNK_SIZE,
help=f"Max rules per chunk (default: {DEFAULT_CHUNK_SIZE})",
)
args = parser.parse_args()
chunks = process_doc(args.doc_path, args.chunk_size)
json.dump(chunks, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Collect subagent result files and feed them to post-review.py.
Replaces what the LLM used to do manually: parsing subagent output,
building JSON for post-review.py.
Usage:
python3 collect-results.py --work-dir /tmp/review-prs-XXXXX [--auto]
"""
import argparse
import json
import os
import re
import subprocess
import sys
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", ".."))
def log(msg):
"""Print to stderr."""
print(msg, file=sys.stderr)
def load_manifest(work_dir):
"""Load and return manifest.json from work_dir."""
manifest_path = os.path.join(work_dir, "manifest.json")
with open(manifest_path) as f:
return json.load(f)
def collect_violations(pr):
"""Collect all violations and validation logs from a PR's subagent results.
Returns (violations_list, validation_log_list).
"""
all_violations = []
all_validation_log = []
for prompt_entry in pr.get("subagent_prompts", []):
results_file = prompt_entry.get("results_file")
if not results_file:
continue
if not os.path.isfile(results_file):
chunk_id = prompt_entry.get("chunk_id", "unknown")
log(f"WARNING: results file missing for PR #{pr['number']} "
f"chunk {chunk_id}: {results_file}")
continue
try:
with open(results_file) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
chunk_id = prompt_entry.get("chunk_id", "unknown")
log(f"WARNING: invalid results file for PR #{pr['number']} "
f"chunk {chunk_id}: {e}")
continue
all_violations.extend(data.get("violations", []))
all_validation_log.extend(data.get("validation_log", []))
return all_violations, all_validation_log
def build_post_review_input(manifest):
"""Build the input JSON structure for post-review.py."""
pr_results = []
for pr in manifest.get("prs", []):
violations, validation_log = collect_violations(pr)
pr_results.append({
"number": pr["number"],
"title": pr.get("title", ""),
"headRefOid": pr.get("headRefOid", ""),
"hasApproval": pr.get("hasApproval", False),
"violations": violations,
"validation_log": validation_log,
})
return {"pr_results": pr_results}
def print_cached_and_progress(manifest):
"""Print cached PR results and progress lines to stderr."""
for cached in manifest.get("cached_prs", []):
number = cached.get("number", "?")
title = cached.get("title", "")
reason = cached.get("reason", "cached")
log(f"CACHED: PR #{number} ({title}) — {reason}")
for line in manifest.get("progress_lines", []):
log(line)
def main():
parser = argparse.ArgumentParser(
description="Collect subagent results and run post-review.py")
parser.add_argument("--work-dir",
required=True,
help="Temp directory with manifest.json and results")
parser.add_argument("--auto",
action="store_true",
help="Pass --auto to post-review.py")
args = parser.parse_args()
# Load manifest
try:
manifest = load_manifest(args.work_dir)
except (OSError, json.JSONDecodeError) as e:
log(f"ERROR: failed to load manifest: {e}")
sys.exit(1)
bot_username = manifest.get("bot_username", "")
pr_repo = manifest.get("pr_repo", "")
auto_mode = args.auto or manifest.get("auto_mode", False)
if not bot_username or not pr_repo:
log("ERROR: manifest missing bot_username or pr_repo")
sys.exit(1)
# Validate manifest values to prevent command injection
if not re.match(
r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]*/[a-zA-Z0-9_][a-zA-Z0-9_.-]*$",
pr_repo,
):
log(f"ERROR: invalid pr_repo format: {pr_repo}")
sys.exit(1)
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", bot_username):
log(f"ERROR: invalid bot_username format: {bot_username}")
sys.exit(1)
# Print cached PRs and progress lines first (before post-review output)
print_cached_and_progress(manifest)
# Build post-review input
post_review_data = build_post_review_input(manifest)
# Collection stats
total_chunks = sum(
len(pr.get("subagent_prompts", [])) for pr in manifest.get("prs", []))
results_found = 0
results_missing = 0
for pr in manifest.get("prs", []):
for sp in pr.get("subagent_prompts", []):
rf = sp.get("results_file", "")
if rf and os.path.isfile(rf):
results_found += 1
else:
results_missing += 1
total_violations = sum(
len(pr_r.get("violations", []))
for pr_r in post_review_data.get("pr_results", []))
total_validated = sum(
len(pr_r.get("validation_log", []))
for pr_r in post_review_data.get("pr_results", []))
log(f"\n{'=' * 60}")
log("COLLECTION SUMMARY")
log(f"{'=' * 60}")
log(f"Total subagent chunks: {total_chunks}")
log(f"Results files found: {results_found}")
log(f"Results files missing: {results_missing}")
log(f"Total violations collected: {total_violations}")
log(f"Total validation log entries: {total_validated}")
for pr_r in post_review_data.get("pr_results", []):
v_count = len(pr_r.get("violations", []))
log(f" PR #{pr_r['number']}: {v_count} violations")
log(f"{'=' * 60}\n")
# Write input file
input_path = os.path.join(args.work_dir, "post-review-input.json")
with open(input_path, "w") as f:
json.dump(post_review_data, f, indent=2)
# Log any errors from manifest
for error in manifest.get("errors", []):
log(f"ERROR (from prepare): {error}")
# Run post-review.py
cmd = [
"python3",
os.path.join(SCRIPT_DIR, "post-review.py"),
"--pr-repo",
pr_repo,
"--bot-username",
bot_username,
"--input",
input_path,
]
if auto_mode:
cmd.append("--auto")
result = subprocess.run(cmd,
capture_output=True,
text=True,
cwd=REPO_DIR,
check=False)
# Pass through stderr (summary log)
if result.stderr:
print(result.stderr, file=sys.stderr, end="")
# Pass through stdout (result JSON)
if result.stdout:
print(result.stdout, end="")
sys.exit(result.returncode)
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Discover all best-practice documents and their applicability conditions.
Scans the best-practices directory for .md files and determines which
file-type conditions trigger each document. Conditions are read from an
<!-- applicability: CONDITION --> HTML comment in each file's first 10 lines.
If no comment is found, the script falls back to naming conventions.
Usage:
python3 discover-best-practices.py <best_practices_dir> [--flags ...]
Optional flags (pass the ones that are true for the current PR):
--has-cpp --has-test --has-chromium-src --has-build --has-frontend
--has-android --has-ios --has-patch --has-nala --has-localization
When flags are provided, only documents matching those conditions
(plus "always" documents) are output. When no flags are provided,
all documents are output with their conditions.
Output (JSON):
[
{"doc": "coding-standards.md",
"path": "/abs/path/coding-standards.md",
"condition": "has_cpp_files"},
{"doc": "architecture.md",
"path": "/abs/path/architecture.md",
"condition": "always"},
...
]
"""
import argparse
import json
import os
import re
import sys
# Naming convention fallbacks: prefix/name -> condition
NAMING_CONVENTIONS = {
"coding-standards": "has_cpp_files",
"testing-": "has_test_files",
"build-system": "has_build_files",
"chromium-src": "has_chromium_src",
"frontend": "has_frontend_files",
"android": "has_android_files",
"ios": "has_ios_files",
"patches": "has_patch_files",
"nala": "has_nala_files",
"localization": "has_localization_files",
"style-guide": "has_frontend_files",
"architecture": "always",
"documentation": "always",
}
# Map CLI flags to condition strings
FLAG_TO_CONDITION = {
"has_cpp": "has_cpp_files",
"has_test": "has_test_files",
"has_chromium_src": "has_chromium_src",
"has_build": "has_build_files",
"has_frontend": "has_frontend_files",
"has_android": "has_android_files",
"has_ios": "has_ios_files",
"has_patch": "has_patch_files",
"has_nala": "has_nala_files",
"has_localization": "has_localization_files",
}
def extract_applicability(filepath):
"""Read first 10 lines looking for <!-- applicability: CONDITION -->."""
try:
with open(filepath) as f:
for i, line in enumerate(f):
if i >= 10:
break
m = re.search(r"<!--\s*applicability:\s*(\S+)\s*-->", line,
re.IGNORECASE)
if m:
return m.group(1).lower()
except OSError:
pass
return None
def infer_condition(filename):
"""Infer condition from filename using naming conventions."""
name = filename.lower().removesuffix(".md")
for prefix, condition in NAMING_CONVENTIONS.items():
if name == prefix or name.startswith(prefix):
return condition
# Default: always include unknown docs
return "always"
def discover(bp_dir):
"""Discover all .md files and their conditions."""
results = []
for fname in sorted(os.listdir(bp_dir)):
if not fname.endswith(".md"):
continue
fpath = os.path.join(bp_dir, fname)
if not os.path.isfile(fpath):
continue
condition = extract_applicability(fpath) or infer_condition(fname)
results.append({
"doc": fname,
"path": os.path.abspath(fpath),
"condition": condition,
})
return results
def main():
parser = argparse.ArgumentParser(
description="Discover best-practice documents and their applicability")
parser.add_argument("bp_dir", help="Path to best-practices directory")
parser.add_argument("--has-cpp", action="store_true")
parser.add_argument("--has-test", action="store_true")
parser.add_argument("--has-chromium-src", action="store_true")
parser.add_argument("--has-build", action="store_true")
parser.add_argument("--has-frontend", action="store_true")
parser.add_argument("--has-android", action="store_true")
parser.add_argument("--has-ios", action="store_true")
parser.add_argument("--has-patch", action="store_true")
parser.add_argument("--has-nala", action="store_true")
parser.add_argument("--has-localization", action="store_true")
args = parser.parse_args()
all_docs = discover(args.bp_dir)
# If any filter flags are set, filter to matching docs
active_conditions = set()
any_flag_set = False
for flag_attr, condition in FLAG_TO_CONDITION.items():
if getattr(args, flag_attr.replace("-", "_")):
active_conditions.add(condition)
any_flag_set = True
if any_flag_set:
filtered = [
d for d in all_docs if d["condition"] == "always"
or d["condition"] in active_conditions
]
else:
filtered = all_docs
json.dump(filtered, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Fetch and filter PRs for review.
Handles all PR fetching, filtering, and cache checking in one script
so the LLM doesn't burn tokens on this logic.
Usage: fetch-prs.py [days|page<N>|#<PR>] [open|closed|all]
[--reviewer-priority <username>] [--max-prs <N>]
Examples:
fetch-prs.py # Default: 5 days, open PRs
fetch-prs.py 3 # Last 3 days, open PRs
fetch-prs.py page2 # Page 2 (PRs 21-40), open PRs
fetch-prs.py 7 closed # Last 7 days, closed PRs
fetch-prs.py page1 all # Page 1, all states
fetch-prs.py #12345 # Single PR by number
fetch-prs.py 12345 # Single PR by number (large numbers treated as PR#)
fetch-prs.py 1 open --reviewer-priority user
# Prioritize PRs requesting review from user
fetch-prs.py 1 open --max-prs 10 # Limit to 10 PRs per batch
Output: JSON with "prs" array and "summary" stats.
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
_script_dir = os.path.dirname(os.path.abspath(__file__))
_repo_dir = os.path.normpath(os.path.join(_script_dir, "..", "..", ".."))
PR_REPO = "brave/brave-core"
CACHE_PATH = os.path.join(_repo_dir, ".ignore", "review-prs-cache.json")
ORG_MEMBERS_PATH = os.environ.get(
"BRAVE_ORG_MEMBERS_PATH",
os.path.join(_repo_dir, ".ignore", "org-members.txt"),
)
SKIP_PREFIXES = ["CI run for", "Backport", "Update l10n"]
SKIP_CONTAINS = ["uplift to", "Just to test CI"]
# Pattern for version branches like "1.90.x"
VERSION_BRANCH_RE = r"^\d+\.\d+\.x$"
def parse_args():
mode = "days"
days = 5
page = None
pr_number = None
state = "open"
reviewer_priority = None
max_prs = None
args = sys.argv[1:]
i = 0
while i < len(args):
arg = args[i]
if arg == "--reviewer-priority" and i + 1 < len(args):
reviewer_priority = args[i + 1]
i += 2
continue
if arg == "--max-prs" and i + 1 < len(args):
max_prs = int(args[i + 1])
i += 2
continue
if arg.startswith("#"):
mode = "single"
pr_number = int(arg[1:])
elif arg.startswith("page"):
mode = "page"
page = int(arg[4:])
elif arg in ("open", "closed", "all"):
state = arg
else:
try:
num = int(arg)
# Large numbers (>365) are PR numbers, not day counts
if num > 365:
mode = "single"
pr_number = num
else:
days = num
except ValueError:
pass
i += 1
return mode, days, page, pr_number, state, reviewer_priority, max_prs
def has_any_approval(pr):
"""Check if a PR has any approval, even if reviewDecision isn't APPROVED."""
if pr.get("reviewDecision") == "APPROVED":
return True
for review in pr.get("latestReviews", []):
if review.get("state") == "APPROVED":
return True
return False
def fetch_single_pr(pr_number):
fields = ("number,title,updatedAt,author,isDraft,"
"headRefOid,baseRefName,reviewDecision,"
"latestReviews,reviewRequests")
result = subprocess.run(
[
"gh",
"pr",
"view",
str(pr_number),
"--repo",
PR_REPO,
"--json",
fields,
],
capture_output=True,
text=True,
check=True,
)
return [json.loads(result.stdout)]
def is_requested_reviewer(pr, username):
"""Check if the given username is a requested reviewer on the PR."""
if not username:
return False
for req in pr.get("reviewRequests", []):
login = req.get("login", "")
if not login:
login = req.get("name", "")
if login.lower() == username.lower():
return True
return False
def fetch_prs(mode, _days, page, pr_number, state):
if mode == "single":
return fetch_single_pr(pr_number)
fields = ("number,title,updatedAt,author,isDraft,"
"headRefOid,baseRefName,reviewDecision,"
"latestReviews,reviewRequests")
base_cmd = [
"gh",
"pr",
"list",
"--repo",
PR_REPO,
"--state",
state,
"--json",
fields,
]
if mode == "page":
limit = page * 20
result = subprocess.run(
base_cmd + ["--limit", str(limit)],
capture_output=True,
text=True,
check=True,
)
prs = json.loads(result.stdout)
prs.sort(key=lambda p: p.get("updatedAt", ""), reverse=True)
start = (page - 1) * 20
return prs[start:start + 20]
result = subprocess.run(
base_cmd + ["--limit", "500"],
capture_output=True,
text=True,
check=True,
)
prs = json.loads(result.stdout)
# Sort by updatedAt descending (newest first) since --sort is
# not available in all gh versions
prs.sort(key=lambda p: p.get("updatedAt", ""), reverse=True)
return prs
def load_cache():
try:
with open(CACHE_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_cache(cache):
with open(CACHE_PATH, "w") as f:
json.dump(cache, f, indent=2)
def load_org_members():
"""Load Brave org member logins from the cached file."""
if not os.path.isfile(ORG_MEMBERS_PATH):
print(
f"ERROR: org members file not found at {ORG_MEMBERS_PATH}\n"
"Set BRAVE_ORG_MEMBERS_PATH to the correct location.",
file=sys.stderr,
)
sys.exit(1)
with open(ORG_MEMBERS_PATH) as f:
return set(line.strip() for line in f if line.strip())
def is_version_branch(branch_name):
"""Check if a branch name is a version branch (e.g., 1.90.x)."""
return bool(re.match(VERSION_BRANCH_RE, branch_name or ""))
def should_skip_title(title):
for prefix in SKIP_PREFIXES:
if title.startswith(prefix):
return True
for pattern in SKIP_CONTAINS:
if pattern in title:
return True
return False
def get_cutoff(mode, days, cache):
"""Determine the cutoff time for filtering PRs.
Uses the last successful run timestamp from the cache if available,
falling back to N days ago. This prevents gaps if a cron run is missed.
"""
if mode != "days":
return None
last_run = cache.get("_last_run")
if last_run:
return datetime.fromisoformat(last_run)
return datetime.now(timezone.utc) - timedelta(days=days)
def filter_prs(prs, mode, days, cache, org_members, reviewer_priority=None):
cutoff = get_cutoff(mode, days, cache)
approved = set(cache.get("_approved", []))
to_review = []
cached_prs = []
uplift_prs = []
skipped_filtered = 0
skipped_cached = 0
skipped_approved = 0
skipped_external = 0
skipped_uplift = 0
cache_dirty = False
for pr in prs:
if pr.get("isDraft"):
skipped_filtered += 1
continue
if should_skip_title(pr.get("title", "")):
skipped_filtered += 1
continue
# Skip PRs targeting version branches (uplifts)
if is_version_branch(pr.get("baseRefName", "")):
skipped_uplift += 1
uplift_prs.append(pr)
continue
# Skip PRs from external contributors (non-org members)
# Exception: allow through if bot is a requested reviewer on the PR
author = pr.get("author", {}).get("login", "")
if org_members and author not in org_members:
if reviewer_priority and is_requested_reviewer(
pr, reviewer_priority):
pass # Bot was asked to review this contributor PR
else:
skipped_external += 1
continue
if cutoff and mode == "days":
updated = datetime.fromisoformat(pr["updatedAt"].replace(
"Z", "+00:00"))
if updated < cutoff:
# Don't filter out PRs where the bot is explicitly requested
if not (reviewer_priority
and is_requested_reviewer(pr, reviewer_priority)):
skipped_filtered += 1
continue
pr_num = str(pr["number"])
head_sha = pr.get("headRefOid", "")
# Bot previously approved this PR — don't come back UNLESS the bot
# has been explicitly re-requested as a reviewer AND new commits have
# landed since the prior review. In that case the prior approval is
# stale and must be cleared so the bot can re-approve if appropriate.
if pr_num in approved:
is_rerequest_on_new_sha = (reviewer_priority
and is_requested_reviewer(
pr, reviewer_priority)
and cache.get(pr_num) != head_sha)
if is_rerequest_on_new_sha:
approved.discard(pr_num)
cache["_approved"] = sorted(approved)
cache_dirty = True
else:
skipped_approved += 1
continue
if cache.get(pr_num) == head_sha:
# If the bot is a requested reviewer, force a full re-review
# even if the SHA hasn't changed (explicit re-request)
if reviewer_priority and is_requested_reviewer(
pr, reviewer_priority):
pass # Fall through to to_review
else:
skipped_cached += 1
cached_prs.append(pr)
continue
to_review.append(pr)
if cache_dirty:
save_cache(cache)
return (
to_review,
cached_prs,
uplift_prs,
skipped_filtered,
skipped_cached,
skipped_approved,
skipped_external,
skipped_uplift,
)
def main():
mode, days, page, pr_number, state, reviewer_priority, max_prs = parse_args(
)
prs = fetch_prs(mode, days, page, pr_number, state)
org_members = load_org_members()
if mode == "single":
# Skip all filtering for single PR review
to_review = prs
cached_prs = []
uplift_prs = []
skipped_filtered = 0
skipped_cached = 0
skipped_approved = 0
skipped_external = 0
skipped_uplift = 0
else:
cache = load_cache()
(
to_review,
cached_prs,
uplift_prs,
skipped_filtered,
skipped_cached,
skipped_approved,
skipped_external,
skipped_uplift,
) = filter_prs(prs, mode, days, cache, org_members, reviewer_priority)
# Sort PRs so those requesting review from the priority user come first
if reviewer_priority:
to_review.sort(key=lambda pr: 0
if is_requested_reviewer(pr, reviewer_priority) else 1)
cached_prs.sort(key=lambda pr: 0
if is_requested_reviewer(pr, reviewer_priority) else 1)
# Apply max-prs limit after sorting (so priority PRs are kept first)
skipped_max_prs = 0
if max_prs is not None and len(to_review) > max_prs:
skipped_max_prs = len(to_review) - max_prs
to_review = to_review[:max_prs]
def pr_entry(pr):
author = pr.get("author", {}).get("login", "unknown")
entry = {
"number": pr["number"],
"title": pr["title"],
"headRefOid": pr["headRefOid"],
"author": author,
"hasApproval": has_any_approval(pr),
}
if reviewer_priority:
entry["isRequestedReviewer"] = is_requested_reviewer(
pr, reviewer_priority)
if org_members and author not in org_members:
entry["isExternalContributor"] = True
return entry
output = {
"prs": [pr_entry(pr) for pr in to_review],
"cached_prs": [pr_entry(pr) for pr in cached_prs],
"uplift_prs": [pr_entry(pr) for pr in uplift_prs],
"summary": {
"total_fetched": len(prs),
"to_review": len(to_review),
"cached_with_possible_threads": len(cached_prs),
"skipped_filtered": skipped_filtered,
"skipped_cached": skipped_cached,
"skipped_approved": skipped_approved,
"skipped_external": skipped_external,
"skipped_uplift": skipped_uplift,
"skipped_max_prs": skipped_max_prs,
},
}
json.dump(output, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Phase 3 post-processing for review-prs: posting, dedup, cache, notifications.
Handles all post-review operations without LLM token usage:
- Cache updates
- Violation prioritization and capping
- Rule link validation
- Deduplication against existing comments
- Posting inline reviews (with fallbacks)
- Approval gate
- Summary output
Usage:
python3 post-review.py --pr-repo <repo>
--bot-username <username> [--auto] < input.json
python3 post-review.py --pr-repo <repo>
--bot-username <username> [--auto] --input <file>
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", ".."))
CACHE_PATH = os.path.join(REPO_DIR, ".ignore", "review-prs-cache.json")
MANAGE_BP_IDS = os.path.join(REPO_DIR, "script", "manage-bp-ids.py")
CHECK_CAN_APPROVE = os.path.join(SCRIPT_DIR, "scripts", "check-can-approve.py")
UPDATE_CACHE = os.path.join(SCRIPT_DIR, "update-cache.py")
MAX_COMMENTS_PER_PR = 5
NITS_THRESHOLD = 3 # Drop nits if >= this many higher-severity comments
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
# Diff line range cache: {pr_number: {file_path: [(start, end), ...]}}
_diff_line_cache = {}
def log(msg):
"""Print to stderr."""
print(msg, file=sys.stderr)
def run_cmd(cmd, input_data=None, timeout=30, check=False):
"""Run a command and return (returncode, stdout, stderr)."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
input=input_data,
cwd=REPO_DIR,
check=False,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd,
result.stdout, result.stderr)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", f"command not found: {cmd[0]}"
def fetch_diff_line_ranges(repo, pr_number):
"""Fetch PR diff and parse valid new-side line ranges per file.
Returns {file_path: [(start, end), ...]} where each tuple is an
inclusive range of lines present in the diff on the RIGHT side.
"""
if pr_number in _diff_line_cache:
return _diff_line_cache[pr_number]
rc, out, err = run_cmd(
["gh", "pr", "diff", "--repo", repo,
str(pr_number)],
timeout=120,
)
if rc != 0:
log(f"WARNING: failed to fetch diff for PR #{pr_number}: {err}")
_diff_line_cache[pr_number] = {}
return {}
ranges = {}
current_file = None
for line in out.split("\n"):
# Track current file from diff headers
if line.startswith("+++ b/"):
current_file = line[6:]
if current_file not in ranges:
ranges[current_file] = []
elif line.startswith("@@ ") and current_file:
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
m = re.search(r'\+(\d+)(?:,(\d+))?', line)
if m:
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) else 1
if count > 0:
ranges[current_file].append((start, start + count - 1))
_diff_line_cache[pr_number] = ranges
return ranges
def correct_line_for_diff(repo, pr_number, file_path, line):
"""If line is not within any diff hunk for the file,
find the nearest valid line.
Returns the corrected line number, or None if the file isn't in the diff.
"""
ranges = fetch_diff_line_ranges(repo, pr_number)
file_ranges = ranges.get(file_path)
if not file_ranges:
return None
# Check if line is already in a valid range
for start, end in file_ranges:
if start <= line <= end:
return line
# Find the nearest valid line
best_line = None
best_dist = float("inf")
for start, end in file_ranges:
for candidate in (start, end):
dist = abs(candidate - line)
if dist < best_dist:
best_dist = dist
best_line = candidate
if best_line is not None:
log(f"LINE_CORRECTED: {file_path}:{line} -> "
f"{file_path}:{best_line} (nearest diff line)")
return best_line
def update_cache(pr_number, head_ref_oid, approve=False):
"""Update the review cache for a PR."""
cmd = ["python3", UPDATE_CACHE, str(pr_number), head_ref_oid]
if approve:
cmd.append("--approve")
rc, _out, err = run_cmd(cmd)
if rc != 0:
log(f"WARNING: cache update failed for PR #{pr_number}: {err}")
return rc == 0
def prioritize_violations(violations, has_approval):
"""Sort and cap violations per the rules.
Returns (kept, dropped_count).
"""
if not violations:
return [], 0
# Sort by severity
violations.sort(
key=lambda v: SEVERITY_ORDER.get(v.get("severity", "low"), 2))
if has_approval:
# Approved PRs: high-severity only
kept = [v for v in violations if v.get("severity") == "high"]
dropped = len(violations) - len(kept)
if dropped:
log(f"CAPPED: dropped {dropped} "
"medium/low violations "
"(PR has approval)")
return kept[:MAX_COMMENTS_PER_PR], dropped
high = [v for v in violations if v.get("severity") == "high"]
medium = [v for v in violations if v.get("severity") == "medium"]
low = [v for v in violations if v.get("severity") == "low"]
kept = list(high)
remaining_slots = MAX_COMMENTS_PER_PR - len(kept)
# Fill with medium
if remaining_slots > 0:
kept.extend(medium[:remaining_slots])
remaining_slots = MAX_COMMENTS_PER_PR - len(kept)
# Only include low (nits) if fewer than
# NITS_THRESHOLD higher-severity comments
higher_count = len(high) + min(len(medium),
MAX_COMMENTS_PER_PR - len(high))
if higher_count < NITS_THRESHOLD and remaining_slots > 0:
kept.extend(low[:remaining_slots])
total_input = len(violations)
dropped = total_input - len(kept)
if dropped > 0:
log(f"CAPPED: dropped {dropped} violations "
f"(kept {len(kept)} most important)")
return kept, dropped
def validate_rule_link(violation):
"""Validate a violation's rule_link. Returns True if valid or no link.
If invalid, strips the link from draft_comment and returns False.
"""
rule_link = violation.get("rule_link")
if not rule_link:
return True # No link to validate
if not os.path.isfile(MANAGE_BP_IDS):
return True # Can't validate, skip
# Extract fragment ID and doc from URL
# URL format: https://github.com/.../docs/best-practices/<doc>.md#<ID>
match = re.search(r'/([^/]+\.md)#([A-Za-z0-9_-]+)$', rule_link)
if not match:
return True # Can't parse, leave as-is
doc_name = match.group(1)
fragment_id = match.group(2)
rc, _out, _err = run_cmd([
"python3", MANAGE_BP_IDS, "--check-link", fragment_id, "--doc",
doc_name
])
if rc != 0:
# Invalid link — strip it from draft_comment
file_path = violation.get("file", "?")
line = violation.get("line", "?")
log(f"INVALID_LINK: stripped broken link "
f"#{fragment_id} from {file_path}:{line}")
# Strip [best practice](...) link pattern from draft_comment
draft = violation.get("draft_comment", "")
draft = re.sub(r'\[best practice\]\([^)]*\)', '', draft).strip()
violation["draft_comment"] = draft
return False
return True
def embed_rule_link_in_comment(violation):
"""Ensure rule_link is embedded in draft_comment as a clickable link.
If the violation has a rule_link and the draft_comment doesn't already
contain it, append a markdown link at the end.
"""
rule_link = violation.get("rule_link")
draft = violation.get("draft_comment", "")
if not rule_link or not draft:
return
# Check if the link (or its fragment) is already in the comment
if rule_link in draft:
return
# Also check if a markdown link to the same anchor exists
match = re.search(r'#([A-Za-z0-9_-]+)$', rule_link)
if match:
fragment = match.group(1)
# Check for [text](url#fragment) pattern already present
if f"#{fragment})" in draft:
return
# Append a best practice link
rule_name = violation.get("rule", "best practice")
violation["draft_comment"] = f"{draft} ([{rule_name}]({rule_link}))"
def filter_violations_by_rule_link(violations):
"""Drop violations missing rule_link.
Unless they are high-severity bug/correctness.
Returns filtered list.
"""
kept = []
for v in violations:
if v.get("rule_link"):
kept.append(v)
elif v.get("severity") == "high":
# High-severity bug/correctness findings can skip rule_link
kept.append(v)
else:
file_path = v.get("file", "?")
line = v.get("line", "?")
snippet = (v.get("draft_comment", ""))[:60]
log(f'DROPPED: no rule_link for {file_path}:{line} — "{snippet}"')
return kept
def fetch_existing_comments(repo, pr_number):
"""Fetch existing review comments on a PR.
Returns list of {path, line, body, user}.
"""
rc, out, _err = run_cmd([
"gh",
"api",
f"repos/{repo}/pulls/{pr_number}/comments",
"--paginate",
"--jq",
'[.[] | {path, line, body, user: .user.login}]',
],
timeout=60)
if rc != 0 or not out:
return []
try:
# gh --paginate with --jq may output multiple JSON arrays
# Concatenate them
comments = []
for chunk in re.split(r'\]\s*\[', out):
chunk = chunk.strip()
if not chunk.startswith('['):
chunk = '[' + chunk
if not chunk.endswith(']'):
chunk = chunk + ']'
try:
comments.extend(json.loads(chunk))
except json.JSONDecodeError:
pass
return comments
except Exception:
return []
def _rule_dedup_key(violation):
"""Return a stable dedup key for a violation's rule.
Prefers the URL fragment from rule_link (e.g. '#CS-044') over the
human-written rule string, which varies across subagents for the same rule.
Falls back to the lowercased rule string if no link is present.
Returns None if neither is available.
"""
rule_link = violation.get("rule_link", "")
if rule_link:
if "#" in rule_link:
return rule_link.split("#", 1)[1].lower()
return rule_link.lower()
rule = violation.get("rule", "").strip()
if rule:
return rule.lower()
return None
def deduplicate_batch_violations(violations):
"""Deduplicate violations within a batch from multiple subagent chunks.
Multiple subagents independently review the same PR and often flag the
same issue with different rule names and slightly different line numbers.
We use three dedup passes:
1. (file, rule_key) — same formal rule on the same file. rule_key is
derived from the rule_link fragment (#RULE-ID) so it's stable across
varying human-written rule strings.
2. (file, line) — different rule, same exact line.
3. file-only — for violations with no rule key and no line, keep
only the first finding per file (avoids duplicate "bug" comments that
lack a formal rule).
Returns filtered list.
"""
seen_file_rule = set()
seen_file_line = set()
seen_file_no_rule = set()
kept = []
for v in violations:
file_path = v.get("file", "")
line = v.get("line")
rule_key = _rule_dedup_key(v)
# Pass 1 — dedup by (file, rule_key)
if rule_key:
file_rule_key = (file_path, rule_key)
if file_rule_key in seen_file_rule:
log(f"BATCH_DEDUP: skipped {file_path}:{line}"
f" — duplicate rule key '{rule_key}'")
continue
seen_file_rule.add(file_rule_key)
# Pass 2 — dedup by (file, line) regardless of rule
if line is not None:
file_line_key = (file_path, line)
if file_line_key in seen_file_line:
log(f"BATCH_DEDUP: skipped {file_path}:{line}"
f" — dup file+line")
continue
seen_file_line.add(file_line_key)
# Pass 3 — for informal "bug" findings with no rule key and no line,
# keep only the first comment per file to avoid duplicate bug reports
if not rule_key and line is None:
if file_path in seen_file_no_rule:
log(f"BATCH_DEDUP: skipped {file_path}"
f" — dup no-rule finding")
continue
seen_file_no_rule.add(file_path)
kept.append(v)
dropped = len(violations) - len(kept)
if dropped:
log(f"BATCH_DEDUP: dropped {dropped} cross-chunk duplicates"
f" (kept {len(kept)})")
return kept
def deduplicate_violations(violations, existing_comments):
"""Remove violations where any existing comment exists on same file+line.
Returns filtered list.
"""
# Build set of (path, line) from existing comments
existing = set()
comment_authors = {} # (path, line) -> user
for c in existing_comments:
path = c.get("path", "")
line = c.get("line")
if path and line is not None:
key = (path, line)
existing.add(key)
comment_authors[key] = c.get("user", "unknown")
kept = []
for v in violations:
key = (v.get("file", ""), v.get("line"))
if key in existing:
user = comment_authors.get(key, "unknown")
log(f"DEDUP: skipped {v.get('file')}:"
f"{v.get('line')} — already "
f"commented by {user}")
else:
kept.append(v)
return kept
def post_batch_review(repo, pr_number, violations, head_sha):
"""Post violations as a single inline review.
Returns (review_url, posted_count).
Corrects line numbers that fall outside diff hunks before posting.
Falls back to individual comments if batch fails.
"""
if not violations:
return None, 0
# Correct line numbers against the actual diff before posting
corrected_violations = []
for v in violations:
corrected_line = correct_line_for_diff(repo, pr_number, v["file"],
v["line"])
if corrected_line is None:
log(f"DROPPED: {v['file']}:{v['line']} — file not in diff")
continue
v["line"] = corrected_line
corrected_violations.append(v)
if not corrected_violations:
return None, 0
violations = corrected_violations
comments = []
for v in violations:
comments.append({
"path": v["file"],
"line": v["line"],
"side": "RIGHT",
"body": v["draft_comment"],
})
payload = json.dumps({
"event": "COMMENT",
"body": "",
"comments": comments,
})
rc, out, _err = run_cmd(
[
"gh", "api", f"repos/{repo}/pulls/{pr_number}/reviews", "--method",
"POST", "--input", "-"
],
input_data=payload,
timeout=60,
)
if rc == 0:
try:
resp = json.loads(out)
return resp.get("html_url", ""), len(comments)
except json.JSONDecodeError:
return "", len(comments)
# Batch failed — fall back to individual comments
log(f"WARNING: batch review failed for PR "
f"#{pr_number}, falling back to individual comments")
posted = 0
review_url = ""
for v in violations:
individual_payload = json.dumps({
"body": v["draft_comment"],
"commit_id": head_sha,
"path": v["file"],
"line": v["line"],
"side": "RIGHT",
})
rc2, out2, err2 = run_cmd(
[
"gh", "api", f"repos/{repo}/pulls/{pr_number}/comments",
"--method", "POST", "--input", "-"
],
input_data=individual_payload,
timeout=30,
)
if rc2 == 0:
posted += 1
if not review_url:
try:
resp2 = json.loads(out2)
review_url = resp2.get("html_url", "")
except json.JSONDecodeError:
pass
else:
log(f"ERROR: failed to post inline comment "
f"for {v['file']}:{v['line']}: {err2}")
return review_url, posted
def submit_approval(repo, pr_number):
"""Submit an APPROVE review. Returns html_url or None."""
payload = json.dumps({"event": "APPROVE", "body": ""})
rc, out, _err = run_cmd(
[
"gh", "api", f"repos/{repo}/pulls/{pr_number}/reviews", "--method",
"POST", "--input", "-"
],
input_data=payload,
timeout=30,
)
if rc == 0:
try:
resp = json.loads(out)
return resp.get("html_url", "")
except json.JSONDecodeError:
return ""
log(f"ERROR: failed to submit approval for PR #{pr_number}: {err}")
return None
def check_can_approve(pr_number, bot_username):
"""Run the approval gate script. Returns True if approval is allowed."""
rc, _out, _err = run_cmd(
["python3", CHECK_CAN_APPROVE,
str(pr_number), bot_username],
timeout=60,
)
return rc == 0
def pr_url(repo, number):
return f"https://github.com/{repo}/pull/{number}"
def pr_link(repo, number, title=None):
url = pr_url(repo, number)
link = f"[PR #{number}]({url})"
if title:
link += f" ({title})"
return link
def process_pr(pr_data, repo, bot_username, auto_mode):
"""Process a single PR. Returns result dict."""
number = pr_data["number"]
title = pr_data.get("title", "")
head_sha = pr_data.get("headRefOid", "")
has_approval = pr_data.get("hasApproval", False)
violations = list(pr_data.get("violations", []))
link = pr_link(repo, number, title)
result = {
"number": number,
"status": "skipped",
"comments_posted": 0,
"review_url": "",
}
# 1. Always update cache
update_cache(number, head_sha)
try:
# 2. Filter violations missing rule_link (unless high-severity)
violations = filter_violations_by_rule_link(violations)
# 3. Deduplicate within batch (cross-chunk duplicates)
violations = deduplicate_batch_violations(violations)
# 4. Prioritize and cap
violations, _dropped = prioritize_violations(violations, has_approval)
# 5. Validate rule links
for v in violations:
validate_rule_link(v)
# 6. Embed rule_link into draft_comment for clickable links
for v in violations:
embed_rule_link_in_comment(v)
# 7. Deduplicate against existing comments
existing_comments = fetch_existing_comments(repo, number)
violations = deduplicate_violations(violations, existing_comments)
if not violations:
# No violations — attempt approval
if check_can_approve(number, bot_username):
approval_url = submit_approval(repo, number)
if approval_url is not None:
update_cache(number, head_sha, approve=True)
result["status"] = "approved"
result["review_url"] = approval_url
log(f"APPROVE: {link} - no violations, approved")
else:
result["status"] = "skipped"
log(f"AUTO: {link} - SKIPPED: approval submission failed")
else:
# Can't approve but no violations to post
result["status"] = "approved" # Effectively clean
log(f"AUTO: {link} - no violations")
return result
if auto_mode:
# Post violations
review_url, posted = post_batch_review(repo, number, violations,
head_sha)
result["status"] = "posted"
result["comments_posted"] = posted
result["review_url"] = review_url or ""
detail_lines = []
for v in violations:
detail_lines.append(
f" - {v['file']}:{v['line']} ({v.get('rule', 'unknown')})"
)
details = "\n".join(detail_lines)
log(f"AUTO: {link} - posted {posted} "
f"comments - {review_url}\n{details}")
else:
# Non-auto: output violations for LLM to present
result["status"] = "pending"
result["violations"] = violations
log(f"AUTO: {link} - {len(violations)} violations pending review")
except Exception as e:
log(f"AUTO: {link} - SKIPPED: {e}")
result["status"] = "skipped"
return result
def main():
parser = argparse.ArgumentParser(
description="Phase 3 post-processing for review-prs")
parser.add_argument("--pr-repo", required=True, help="owner/repo for PRs")
parser.add_argument("--bot-username",
required=True,
help="Bot GitHub username")
parser.add_argument("--auto", action="store_true", help="Auto-post mode")
parser.add_argument("--input",
dest="input_file",
help="Input JSON file (default: stdin)")
args = parser.parse_args()
# Read input
if args.input_file:
with open(args.input_file) as f:
data = json.load(f)
else:
data = json.load(sys.stdin)
pr_results_input = data.get("pr_results", [])
if not pr_results_input:
log("No PR results to process.")
output = {
"results": [],
"summary": {
"prs_reviewed": 0,
"prs_with_violations": 0,
"total_comments_posted": 0,
"prs_approved": 0,
}
}
print(json.dumps(output, indent=2))
return
results = []
for pr_data in pr_results_input:
result = process_pr(pr_data, args.pr_repo, args.bot_username,
args.auto)
results.append(result)
# Build summary
prs_reviewed = len(results)
prs_with_violations = sum(1 for r in results if r["status"] == "posted")
total_comments = sum(r["comments_posted"] for r in results)
prs_approved = sum(1 for r in results if r["status"] == "approved")
summary = {
"prs_reviewed": prs_reviewed,
"prs_with_violations": prs_with_violations,
"total_comments_posted": total_comments,
"prs_approved": prs_approved,
}
# Print summary block to stderr
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
summary_lines = [
"========================================",
"AUTO REVIEW SUMMARY",
f"Date: {now}",
f"PRs reviewed: {prs_reviewed}",
f"PRs with violations: {prs_with_violations}",
f"Total comments posted: {total_comments}",
"Cached PRs processed: 0",
f"PRs approved: {prs_approved}",
"",
"RESULTS:",
]
for r in results:
num = r["number"]
# Find title from input
title = ""
for pr_data in pr_results_input:
if pr_data["number"] == num:
title = pr_data.get("title", "")
break
link = pr_link(args.pr_repo, num, title)
if r["status"] == "approved":
summary_lines.append(f" \u2705 {link} - no violations, approved")
elif r["status"] == "posted":
url = r.get("review_url", "")
summary_lines.append(
f" \u274c {link} - {r['comments_posted']} comments - {url}")
elif r["status"] == "skipped":
summary_lines.append(f" \u23ed\ufe0f {link} - SKIPPED")
elif r["status"] == "pending":
count = len(r.get("violations", []))
summary_lines.append(
f" \u23f3 {link} - {count} violations pending")
summary_lines.append("========================================")
log("\n".join(summary_lines))
# Output result JSON to stdout
# Strip internal violations from results before output
clean_results = []
for r in results:
out_r = {
"number": r["number"],
"status": r["status"],
"comments_posted": r["comments_posted"],
"review_url": r["review_url"],
}
if r["status"] == "pending" and "violations" in r:
out_r["violations"] = r["violations"]
clean_results.append(out_r)
output = {
"results": clean_results,
"summary": summary,
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Phase 1 pre-work for the review-prs skill.
Produces a work directory with prompt files and a lightweight manifest.
Zero prompt construction tokens required from the LLM — subagent prompts
are written to files, not embedded in JSON.
Usage:
python3 prepare-review.py [days|page<N>|#<PR>]
[open|closed|all] [--auto]
[--reviewer-priority] [--max-prs N]
"""
import importlib.util
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Repo root: .claude/skills/review-prs -> .claude/skills -> .claude -> repo root
_REPO_DIR = os.path.normpath(os.path.join(_SCRIPT_DIR, "..", "..", ".."))
# Add to path for imports
sys.path.insert(0, _SCRIPT_DIR)
# Import fetch-prs functions (the module uses if __name__ guard)
_fp_spec = importlib.util.spec_from_file_location(
"fetch_prs", os.path.join(_SCRIPT_DIR, "fetch-prs.py"))
_fp_mod = importlib.util.module_from_spec(_fp_spec)
_fp_spec.loader.exec_module(_fp_mod)
# Import chunk-best-practices functions
_cb_spec = importlib.util.spec_from_file_location(
"chunk_best_practices", os.path.join(_SCRIPT_DIR,
"chunk-best-practices.py"))
_cb_mod = importlib.util.module_from_spec(_cb_spec)
_cb_spec.loader.exec_module(_cb_mod)
# Import extract-pr-images functions
_ei_spec = importlib.util.spec_from_file_location(
"extract_pr_images",
os.path.join(_SCRIPT_DIR, "scripts", "extract-pr-images.py"))
_ei_mod = importlib.util.module_from_spec(_ei_spec)
_ei_spec.loader.exec_module(_ei_mod)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
PR_REPO = "brave/brave-core"
DEFAULT_BRANCH = "master"
CACHE_PATH = os.path.join(_REPO_DIR, ".ignore", "review-prs-cache.json")
BP_DIR = os.path.join(_REPO_DIR, "docs", "best-practices")
BP_LINK_BASE = (f"https://github.com/{PR_REPO}/tree/"
f"{DEFAULT_BRANCH}/docs/best-practices")
TARGET_REPO_PATH = _REPO_DIR
def log(msg):
print(msg, file=sys.stderr)
# ---------------------------------------------------------------------------
# Org members / trusted reviewers
# ---------------------------------------------------------------------------
def load_org_members():
org_members_path = os.environ.get(
"BRAVE_ORG_MEMBERS_PATH",
os.path.join(_REPO_DIR, ".ignore", "org-members.txt"),
)
if not os.path.isfile(org_members_path):
log(f"ERROR: org members file not found at {org_members_path}")
log("Set BRAVE_ORG_MEMBERS_PATH to the correct location.")
sys.exit(1)
with open(org_members_path) as f:
members = set(line.strip() for line in f if line.strip())
trusted_reviewers_path = os.path.join(_SCRIPT_DIR, "scripts",
"trusted-reviewers.txt")
try:
with open(trusted_reviewers_path) as f:
members |= set(line.strip() for line in f if line.strip())
except FileNotFoundError:
pass
return members
# ---------------------------------------------------------------------------
# CLI parsing
# ---------------------------------------------------------------------------
def parse_args():
auto_mode = False
reviewer_priority = False
max_prs = None
fetch_args = []
args = sys.argv[1:]
i = 0
while i < len(args):
arg = args[i]
if arg == "--auto":
auto_mode = True
elif arg == "--reviewer-priority":
reviewer_priority = True
elif arg == "--max-prs" and i + 1 < len(args):
max_prs = int(args[i + 1])
i += 1
else:
fetch_args.append(arg)
i += 1
return auto_mode, reviewer_priority, max_prs, fetch_args
# ---------------------------------------------------------------------------
# Bot username
# ---------------------------------------------------------------------------
def resolve_bot_username():
result = subprocess.run(
["gh", "api", "user", "--jq", ".login"],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if result.returncode != 0:
log(f"ERROR: failed to resolve bot username: {result.stderr}")
sys.exit(1)
return result.stdout.strip()
# ---------------------------------------------------------------------------
# Branch helpers
# ---------------------------------------------------------------------------
def is_feature_branch(base_ref):
"""Return True if base_ref is a non-default, non-version feature branch."""
if not base_ref:
return False
if base_ref == DEFAULT_BRANCH:
return False
if _fp_mod.is_version_branch(base_ref):
return False
return True
# ---------------------------------------------------------------------------
# Diff fetching
# ---------------------------------------------------------------------------
def fetch_diff(pr_number):
result = subprocess.run(
["gh", "pr", "diff", "--repo", PR_REPO,
str(pr_number)],
capture_output=True,
text=True,
timeout=120,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to fetch diff: {result.stderr.strip()}")
return result.stdout
# ---------------------------------------------------------------------------
# File classification from diff
# ---------------------------------------------------------------------------
def parse_diff_line_ranges(diff_text):
"""Parse diff to extract valid new-side line ranges per file.
Returns {file_path: [(start, end), ...]} where each tuple is an
inclusive range of lines present in the diff on the RIGHT (new) side.
These are the only lines where GitHub allows inline review comments.
"""
ranges = {}
current_file = None
for line in diff_text.split("\n"):
if line.startswith("+++ b/"):
current_file = line[6:]
if current_file not in ranges:
ranges[current_file] = []
elif line.startswith("@@ ") and current_file:
m = re.search(r'\+(\d+)(?:,(\d+))?', line)
if m:
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) else 1
if count > 0:
ranges[current_file].append((start, start + count - 1))
return ranges
def format_diff_line_ranges(diff_ranges):
"""Format diff line ranges as a readable string for subagent prompts."""
lines = []
for file_path, file_ranges in sorted(diff_ranges.items()):
range_strs = [f"{s}-{e}" for s, e in file_ranges]
lines.append(f" {file_path}: {', '.join(range_strs)}")
return "\n".join(lines)
def classify_files(diff_text):
files = []
for line in diff_text.splitlines():
if line.startswith("diff --git"):
# Extract b/ path
m = re.search(r" b/(.+)$", line)
if m:
files.append(m.group(1))
flags = {
"has_cpp_files": False,
"has_test_files": False,
"has_chromium_src": False,
"has_build_files": False,
"has_frontend_files": False,
"has_android_files": False,
"has_ios_files": False,
"has_patch_files": False,
"has_nala_files": False,
"has_localization_files": False,
}
for f in files:
fl = f.lower()
base = os.path.basename(fl)
# C++ files
if fl.endswith((".cc", ".h", ".mm")):
flags["has_cpp_files"] = True
# Test files
if (fl.endswith("_test.cc") or fl.endswith("_browsertest.cc")
or fl.endswith("_unittest.cc") or fl.endswith(".test.ts")
or fl.endswith(".test.tsx")):
flags["has_test_files"] = True
# chromium_src
if "chromium_src/" in f:
flags["has_chromium_src"] = True
# Build files
if base in ("build.gn", "deps") or fl.endswith(".gni"):
flags["has_build_files"] = True
# Frontend files
if fl.endswith((".ts", ".tsx", ".html", ".css")):
flags["has_frontend_files"] = True
# Android
if fl.endswith((".java", ".kt")) or "android/" in f:
flags["has_android_files"] = True
# iOS
if fl.endswith(".swift") or "ios/" in f:
flags["has_ios_files"] = True
# Patch files
if fl.endswith(".patch") or "patches/" in f:
flags["has_patch_files"] = True
# Nala files
if (re.search(r"/res/drawable/", f) or re.search(r"/res/values/", f)
or re.search(r"/res/values-night/", f)
or "components/vector_icons/" in f or fl.endswith(".icon")
or fl.endswith(".svg")):
flags["has_nala_files"] = True
# Localization files
if (fl.endswith((".grd", ".grdp", ".xtb")) or "l10n/" in f
or "strings/" in f):
flags["has_localization_files"] = True
return flags
# ---------------------------------------------------------------------------
# Prior comments (reimplementation of filter-pr-reviews.sh in Python)
# ---------------------------------------------------------------------------
def _gh_api_paginated(endpoint):
"""Fetch paginated GitHub API results."""
result = subprocess.run(
["gh", "api", endpoint, "--paginate"],
capture_output=True,
text=True,
timeout=60,
check=False,
)
if result.returncode != 0:
log(f"WARNING: gh api {endpoint} failed: {result.stderr.strip()}")
return []
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return []
def _gh_api(endpoint):
"""Fetch a single GitHub API result (no pagination)."""
result = subprocess.run(
["gh", "api", endpoint],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
log(f"WARNING: gh api {endpoint} failed: {result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def fetch_prior_comments(pr_number, org_members, include_author=None):
"""Reimplement filter-pr-reviews.sh: fetch PR data, reviews, review
comments, issue comments. Filter by org membership. Return markdown."""
repo = PR_REPO
_, _ = repo.split("/", 1)
# Fetch PR data
pr_data = _gh_api(f"repos/{repo}/pulls/{pr_number}")
if not pr_data:
return None, False
pr_title = pr_data.get("title", "")
pr_author = (pr_data.get("user") or {}).get("login", "")
pr_state = pr_data.get("state", "")
pr_merged = pr_data.get("merged", False)
pr_mergeable = pr_data.get("mergeable", "")
# Fetch reviews, review comments, issue comments
reviews = _gh_api_paginated(f"repos/{repo}/pulls/{pr_number}/reviews")
review_comments = _gh_api_paginated(
f"repos/{repo}/pulls/{pr_number}/comments")
issue_comments = _gh_api_paginated(
f"repos/{repo}/issues/{pr_number}/comments")
# Get latest push timestamp
head_sha = (pr_data.get("head") or {}).get("sha", "")
latest_push_ts = ""
if head_sha:
commit_data = _gh_api(f"repos/{repo}/commits/{head_sha}")
if commit_data:
latest_push_ts = ((commit_data.get("commit")
or {}).get("committer") or {}).get("date", "")
# Find latest reviewer activity from org members
latest_reviewer_ts = ""
def _is_org(username):
return username in org_members
for review in reviews:
user = (review.get("user") or {}).get("login", "")
if _is_org(user):
ts = review.get("submitted_at", "")
if ts > latest_reviewer_ts:
latest_reviewer_ts = ts
for comment in review_comments:
user = (comment.get("user") or {}).get("login", "")
if _is_org(user):
ts = comment.get("created_at", "")
if ts > latest_reviewer_ts:
latest_reviewer_ts = ts
for comment in issue_comments:
user = (comment.get("user") or {}).get("login", "")
if _is_org(user):
ts = comment.get("created_at", "")
if ts > latest_reviewer_ts:
latest_reviewer_ts = ts
# Determine who went last
if not latest_reviewer_ts:
who_went_last = "bot"
elif latest_reviewer_ts > latest_push_ts:
who_went_last = "reviewer"
else:
who_went_last = "bot"
# Build markdown output
lines = []
lines.append(f"# PR #{pr_number}: {pr_title}")
lines.append("")
author_status = "(Brave org member)" if _is_org(
pr_author) else "(EXTERNAL)"
lines.append(f"**Author:** @{pr_author} {author_status}")
lines.append(f"**State:** {pr_state}")
lines.append(f"**Merged:** {pr_merged}")
lines.append(f"**Mergeable:** {pr_mergeable}")
lines.append("")
# Include PR body for external contributor PRs if include_author matches
if include_author and include_author == pr_author:
pr_body = pr_data.get("body") or ""
if pr_body:
lines.append(
f"## PR Description (from external contributor @{pr_author})")
lines.append("")
lines.append(pr_body)
lines.append("")
lines.append("## Timestamp Analysis")
lines.append("")
lines.append(f"**Latest Push:** {latest_push_ts}")
lines.append(
f"**Latest Reviewer Activity:** {latest_reviewer_ts or 'None'}")
lines.append(f"**Who Went Last:** {who_went_last}")
lines.append("")
# Reviews section
lines.append("## Reviews")
lines.append("")
if not reviews:
lines.append("No reviews yet.")
lines.append("")
else:
for review in reviews:
user = (review.get("user") or {}).get("login", "")
state = review.get("state", "")
submitted = review.get("submitted_at", "")
body = review.get("body") or ""
if _is_org(user):
lines.append(
f"### @{user} (Brave org member) - {state} - {submitted}")
lines.append("")
if body:
lines.append(body)
lines.append("")
else:
lines.append(f"### @{user} (EXTERNAL) - {state} - {submitted}")
lines.append("")
lines.append("[Review filtered - external user]")
lines.append("")
# Review comments (inline code)
lines.append("## Review Comments (Code)")
lines.append("")
if not review_comments:
lines.append("No review comments.")
lines.append("")
else:
for comment in review_comments:
user = (comment.get("user") or {}).get("login", "")
path = comment.get("path", "")
created = comment.get("created_at", "")
body = comment.get("body") or ""
if _is_org(user):
lines.append(f"### @{user} (Brave org member) - {created}")
lines.append(f"**File:** {path}")
lines.append("")
lines.append(body)
lines.append("")
else:
lines.append(f"### @{user} (EXTERNAL) - {created}")
lines.append(f"**File:** {path}")
lines.append("")
lines.append("[Comment filtered - external user]")
lines.append("")
# Issue comments (discussion)
lines.append("## Discussion Comments")
lines.append("")
if not issue_comments:
lines.append("No discussion comments.")
lines.append("")
else:
for comment in issue_comments:
user = (comment.get("user") or {}).get("login", "")
created = comment.get("created_at", "")
body = comment.get("body") or ""
if _is_org(user):
lines.append(f"### @{user} (Brave org member) - {created}")
lines.append("")
lines.append(body)
lines.append("")
else:
lines.append(f"### @{user} (EXTERNAL) - {created}")
lines.append("")
lines.append("[Comment filtered - external user]")
lines.append("")
markdown = "\n".join(lines)
# Determine if there are any bot comments (will be used to decide
# whether to run resolve-bot-threads)
has_any_comment = bool(reviews) or bool(review_comments) or bool(
issue_comments)
return markdown if has_any_comment else None, has_any_comment
# ---------------------------------------------------------------------------
# Resolve bot threads (subprocess — it uses argparse)
# ---------------------------------------------------------------------------
def resolve_bot_threads(pr_number, bot_username):
result = subprocess.run(
[
sys.executable,
os.path.join(_SCRIPT_DIR, "scripts", "resolve-bot-threads.py"),
str(pr_number),
bot_username,
],
capture_output=True,
text=True,
timeout=60,
cwd=_REPO_DIR,
check=False,
)
if result.returncode != 0:
log(f"WARNING: resolve-bot-threads failed for "
f"#{pr_number}: {result.stderr.strip()}")
return {
"resolved": 0,
"unresolved_bot_threads": 0,
"total_bot_threads": 0
}
try:
data = json.loads(result.stdout)
return {
"resolved": len(data.get("resolved", [])),
"unresolved_bot_threads": data.get("unresolved_bot_threads", 0),
"total_bot_threads": data.get("total_bot_threads", 0),
}
except json.JSONDecodeError:
return {
"resolved": 0,
"unresolved_bot_threads": 0,
"total_bot_threads": 0
}
# ---------------------------------------------------------------------------
# Check-can-approve (subprocess — exits non-zero when can't approve)
# ---------------------------------------------------------------------------
def check_can_approve(pr_number, bot_username):
result = subprocess.run(
[
sys.executable,
os.path.join(_SCRIPT_DIR, "scripts", "check-can-approve.py"),
str(pr_number),
bot_username,
],
capture_output=True,
text=True,
timeout=30,
cwd=_REPO_DIR,
check=False,
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
data = {"can_approve": False, "reason": "Failed to parse output"}
return {
"result": data.get("can_approve", False),
"reason": data.get("reason", "unknown"),
}
# ---------------------------------------------------------------------------
# Submit APPROVE review and update cache
# ---------------------------------------------------------------------------
def submit_approve(pr_number, head_sha):
"""Submit APPROVE review and mark as approved in cache."""
# Submit APPROVE
approve_input = json.dumps({"event": "APPROVE", "body": ""})
result = subprocess.run(
[
"gh", "api", f"repos/{PR_REPO}/pulls/{pr_number}/reviews",
"--method", "POST", "--input", "-"
],
input=approve_input,
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
log(f"WARNING: APPROVE failed for #{pr_number}: {result.stderr.strip()}"
)
return False
# Update cache with --approve
subprocess.run(
[
sys.executable,
os.path.join(_SCRIPT_DIR, "update-cache.py"),
str(pr_number),
head_sha,
"--approve",
],
capture_output=True,
text=True,
timeout=10,
cwd=_REPO_DIR,
check=False,
)
return True
# ---------------------------------------------------------------------------
# Extract PR images (import from script)
# ---------------------------------------------------------------------------
def extract_images(pr_number):
"""Run extract-pr-images.py as subprocess and return images list."""
result = subprocess.run(
[
sys.executable,
os.path.join(_SCRIPT_DIR, "scripts", "extract-pr-images.py"),
str(pr_number),
],
capture_output=True,
text=True,
timeout=60,
cwd=_REPO_DIR,
check=False,
)
if result.returncode != 0:
log(f"WARNING: extract-pr-images failed for "
f"#{pr_number}: {result.stderr.strip()}")
return []
try:
data = json.loads(result.stdout)
return [{
"abs_path": img.get("abs_path", img.get("path", "")),
"source": img.get("source", ""),
"alt": img.get("alt", "")
} for img in data.get("images", [])]
except json.JSONDecodeError:
return []
# ---------------------------------------------------------------------------
# Discover best-practice docs (subprocess — uses argparse)
# ---------------------------------------------------------------------------
def discover_best_practices(file_flags):
cmd = [
sys.executable,
os.path.join(_SCRIPT_DIR, "discover-best-practices.py"),
BP_DIR,
]
flag_map = {
"has_cpp_files": "--has-cpp",
"has_test_files": "--has-test",
"has_chromium_src": "--has-chromium-src",
"has_build_files": "--has-build",
"has_frontend_files": "--has-frontend",
"has_android_files": "--has-android",
"has_ios_files": "--has-ios",
"has_patch_files": "--has-patch",
"has_nala_files": "--has-nala",
"has_localization_files": "--has-localization",
}
for key, flag in flag_map.items():
if file_flags.get(key):
cmd.append(flag)
result = subprocess.run(cmd,
capture_output=True,
text=True,
timeout=30,
check=False)
if result.returncode != 0:
log(f"WARNING: discover-best-practices failed: {result.stderr.strip()}"
)
return []
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return []
# ---------------------------------------------------------------------------
# Chunk best-practice docs (direct import)
# ---------------------------------------------------------------------------
def chunk_doc(doc_path):
return _cb_mod.process_doc(doc_path)
# ---------------------------------------------------------------------------
# Subagent prompt builder
# ---------------------------------------------------------------------------
# The review rules from SKILL.md Steps 3-5, 7-8 — embedded verbatim.
# pylint: disable=line-too-long
_REVIEW_RULES = """\
Review Rules:
- Only flag violations in ADDED lines (+ lines), not existing code.
- If you notice a violation in surrounding context lines (lines without + prefix) or in unchanged code visible in the diff, do NOT comment on it or suggest fixing it -- unless the changes directly affect or break that surrounding code.
- Also flag bugs introduced by the change (e.g., missing string separators, duplicate DEPS entries, code inside wrong #if guard).
- Check surrounding context before making claims. When a violation involves dependencies, includes, or patterns, read the full file context (e.g., the BUILD.gn deps list, existing includes in the file) to verify your claim is accurate. Do NOT claim a PR "adds a dependency" or "introduces a pattern" if it already existed before the PR.
- Only comment on things the PR author introduced. If a dependency, pattern, or architectural issue already existed before this PR, do not flag it — even if it violates a best practice. The PR author is not responsible for pre-existing issues. Focus exclusively on what this PR changes or adds.
- Do not suggest renaming imported symbols defined outside the PR. When a + line imports or calls a function/class/variable from another module, and that symbol's definition is NOT in a file changed by the PR, do not comment on the symbol's naming. The PR author cannot rename it without modifying the upstream module, which is out of scope. Only flag naming issues on symbols that are defined or renamed within the PR's changed files.
- Respect the intent of the PR. If a PR is moving, renaming, or refactoring files, do not suggest restructuring dependencies, changing public_deps vs deps, or reorganizing code that was simply carried over from the old location. The author's goal is to preserve existing behavior, not to optimize the code they're moving. Only flag issues that are actual bugs introduced by the move (e.g., broken paths, missing deps that cause build failures), not "while you're here, you should also fix X" improvements.
- Security-sensitive areas (wallet, crypto, sync, credentials) deserve extra scrutiny — type mismatches, truncation, and correctness issues should use stronger language.
- Do NOT flag: existing code the PR isn't changing, template functions defined in headers, simple inline getters in headers, style preferences not in the documented best practices, include/import ordering (this is handled by formatting tools and linters, not this bot).
- Every claim must be verified in the best practices source document. Do NOT make claims based on general knowledge or assumptions about what "should" be a best practice. If the best practices docs do not contain a rule about something, do NOT flag it as a violation — even if you believe it to be true. For example, do NOT claim an API is "deprecated" or a pattern is "banned" unless the best practices doc explicitly says so. Hallucinated rules erode trust and waste developer time. When in doubt, do not comment.
- Do NOT make claims about what upstream Chromium code does (e.g., "the upstream class overrides X" or "upstream uses pattern Y") unless you read the actual upstream file during validation. Upstream behavior claims are a common hallucination vector. If your violation depends on an upstream comparison, verify it by reading the file -- if you cannot confirm it, drop the violation.
- Base branch awareness: if the PR targets a non-master feature branch (stated at the top of this prompt), do NOT claim that a symbol, file, include, or dependency is missing or doesn't exist just because it isn't in the source tree. It may have been introduced by the base branch. Use the GitHub API to verify it before flagging: `gh api repos/<repo>/contents/<path>?ref=<base_branch>`. Drop the violation if you cannot confirm the absence.
- Comment style: short (1-3 sentences), targeted, acknowledge context. Use "nit:" for genuinely minor/stylistic issues (including missing comments/documentation). Substantive issues (test reliability, correctness, banned APIs) should be direct without "nit:" prefix."""
_BEST_PRACTICE_LINK_REQUIREMENT = """\
Best practice link requirement: each rule in the best practices docs has a stable ID anchor (e.g., <a id="CS-001"></a>) on the line before the heading. For each violation, you MUST include a direct link using that ID. The link format is:
{bp_link_base}/<doc>.md#<ID>
For example, if the heading has <a id="CS-042"></a> above it, the link is ...coding-standards.md#CS-042.
CRITICAL: The rule_link fragment MUST be an exact <a id="..."> value from the rules provided in your chunk. Look for the <a id="..."></a> tag on the line before the heading you're referencing and use that ID verbatim. Do NOT invent IDs, guess ID numbers, or construct anchors from heading text. If no <a id> tag exists for the rule, or if your observation is a general bug/correctness issue that doesn't map to any specific heading, omit the rule_link field entirely.
CRITICAL: Do NOT invent rules or claim things are deprecated/banned without verification. Every best-practice violation you flag MUST correspond to an actual rule provided in your chunk. If you cannot point to the specific heading in the provided rules that contains the rule, do not flag it. Do not rely on general knowledge about Chromium conventions — only flag what is explicitly documented. A hallucinated rule (e.g., claiming an API is "deprecated" when the rules say nothing about it) erodes developer trust and is worse than no comment at all."""
_PRIOR_COMMENTS_RULES = """\
Prior comments re-review rules:
- Do NOT re-raise issues that the author or a reviewer has already explained or justified. If a prior comment thread shows the author explaining why a design choice was made (e.g., "only two subclasses will ever use this, both pass constants"), accept that explanation and do not flag the same issue again.
- Do NOT repeat your own previous comments. If a comment from the bot already raised the same point, skip it — even if the code hasn't changed. The author has already seen it.
- Do NOT flag new issues on re-review that were missed the first time. If an issue existed in the code during the first review and was not caught, do not raise it on a subsequent review — unless it is a serious correctness or security concern. Only flag issues on re-review if they were introduced in commits since the last reviewed commit.
- DO re-raise an issue only if: (a) the author's explanation is factually incorrect or introduces a real risk, OR (b) new code in the latest diff introduces a new instance of the same problem that wasn't previously discussed.
- When in doubt about whether an issue was addressed, err on the side of NOT re-raising it. Repeating resolved feedback is more disruptive than missing a marginal issue."""
_SYSTEMATIC_AUDIT_REQUIREMENT = """\
Systematic Audit Requirement:
CRITICAL — this is what prevents you from stopping after finding a few violations.
You MUST work through your chunk heading by heading, checking every ## rule against the diff. You must output an audit trail listing EVERY ## heading in the chunk with a verdict:
AUDIT:
PASS: ✅ Always Include What You Use (IWYU)
PASS: ✅ Use Positive Form for Booleans and Methods
N/A: ✅ Consistent Naming Across Layers
FAIL: ❌ Don't Use rapidjson
PASS: ✅ Use CHECK for Impossible Conditions
... (one entry per ## heading in the chunk)
Verdicts:
- PASS: Checked the diff — no violation found
- N/A: Rule doesn't apply to the types of changes in this diff
- FAIL: Violation found — must have a corresponding entry in VIOLATIONS
This forces you to explicitly consider every rule rather than satisficing after a few findings."""
_REQUIRED_OUTPUT_FORMAT = """\
Required Output Format:
You MUST return this structured format:
DOCUMENT: {doc} (chunk {chunk_num}/{total_chunks})
[PR #{pr_number}](https://github.com/{pr_repo}/pull/{pr_number}): {title}
AUDIT:
PASS: <rule heading>
N/A: <rule heading>
FAIL: <rule heading>
... (one line per ## heading in the chunk)
SKIPPED_PRIOR:
- file: <path>, issue: <brief description>, reason: <why not re-raised — e.g., "author explained in prior comment that only constant strings are passed", "already flagged in previous review">
NONE (if no prior issues were skipped)
VIOLATIONS:
- file: <path>, line: <line_number>, severity: <"high"|"medium"|"low">, rule: "<rule heading>", rule_link: <full GitHub URL to the rule heading>, issue: <brief description>, draft_comment: <1-3 sentence comment to post>
- ...
NO_VIOLATIONS (if none found)
CRITICAL: The `line` value MUST be a line number within one of the valid diff line ranges listed above. Comments on lines outside the diff will fail to post. If the violation is on a context line (no + prefix), use the nearest + line in the same hunk instead.
Severity guide:
- high: Correctness bugs, use-after-free, security issues, banned APIs, test reliability problems (e.g., RunUntilIdle)
- medium: Substantive best practice violations (wrong container type, missing error handling, architectural issues)
- low: Nits, style preferences, missing docs, naming suggestions, minor cleanup"""
_VALIDATION_INSTRUCTIONS = """\
Source Code Validation (REQUIRED):
After identifying violations from the diff, you MUST validate each one by reading the actual source code.
The target source tree is at: {target_repo_path}
File paths in violations are relative to this directory.
For each violation:
- Use the Read tool to read the actual source file at {target_repo_path}/<file_path> around the flagged line.
- Read surrounding context — functions, class definitions, includes, namespace scope.
- Verify the claim is true. If the violation says "use X instead of Y", confirm X is available and appropriate.
- If it claims something is missing, verify it's actually missing in the full file, not just absent from the diff.
- Deprecation claims require header verification — read the actual header file to confirm.
- Check surrounding context for justification — comments, TODOs, or patterns that explain the code.
- If surrounding code uses the same pattern being flagged, the violation may be invalid.
- If the violation claims upstream code has or lacks something (e.g., "upstream overrides X", "upstream class uses Y"), you MUST read the actual upstream file to confirm. Do not rely on your training data for upstream code state. If you cannot locate and read the upstream file, drop the violation.{base_branch_validation_note}
- Sanitize @mentions — validate against actual PR participants. Fix or strip hallucinated usernames.
- Drop false positives. If reading the source reveals the violation is incorrect, drop it.
- Log each result:
- VALIDATED: <file>:<line> — confirmed, <note>
- VALIDATED_ENHANCED: <file>:<line> — improved with <context>
- VALIDATED_DROP: <file>:<line> — <reason>
After validation, write your final results as a JSON file to: {results_file}
Use the Write tool to create this file with the following format:
{{
"violations": [
{{"file": "path/to/file.cc", "line": 42, "severity": "high", "rule": "Rule heading", "rule_link": "https://...", "issue": "brief description", "draft_comment": "1-3 sentence comment to post"}}
],
"validation_log": ["VALIDATED: file.cc:42 — confirmed", "VALIDATED_DROP: bar.cc:10 — false positive"]
}}
If there are no validated violations, write: {{"violations": [], "validation_log": []}}
CRITICAL: You MUST write the results JSON file even if there are no violations.
CRITICAL: NEVER post reviews, comments, or approvals to GitHub. NEVER use `gh api`, `gh pr review`, `gh pr comment`, or any other command to interact with the GitHub API. Your ONLY job is to analyze the diff against the rules and write the results JSON file. All posting is handled by a separate pipeline script after your results are collected."""
# pylint: enable=line-too-long
def build_subagent_prompt(pr_number,
pr_title,
diff_text,
images,
prior_comments,
bot_username,
chunk,
diff_line_ranges=None,
results_file=None,
target_repo_path=None,
base_ref=None):
"""Build a complete self-contained subagent prompt for a single chunk."""
doc = chunk["doc"]
chunk_index = chunk["chunk_index"]
total_chunks = chunk["total_chunks"]
chunk_content = chunk["content"]
parts = []
# 0. Current date context (so the model knows the actual year)
current_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
parts.append(f"Today's date is {current_date}.")
parts.append("")
# 1. PR number and repo
parts.append(f"Review PR #{pr_number} in {PR_REPO}.")
parts.append(f"PR title: {pr_title}")
if base_ref and is_feature_branch(base_ref):
parts.append(
f"NOTE: This PR targets base branch `{base_ref}`, not "
f"`{DEFAULT_BRANCH}`. Code introduced by `{base_ref}` will not "
f"appear in this diff and may not be present in the source tree "
f"(which reflects `{DEFAULT_BRANCH}`). When validating, do not "
f"assume something is missing just because it is absent from the "
f"source tree — look it up in the base branch first using: "
f"`gh api repos/{PR_REPO}/contents/<path>?ref={base_ref}`")
parts.append("")
# 2. The FULL PR diff (shared prefix for cache efficiency)
parts.append("Here is the PR diff:")
parts.append("```diff")
parts.append(diff_text)
parts.append("```")
parts.append("")
# 2b. Valid line ranges for inline comments
if diff_line_ranges:
parts.append("## Valid Line Ranges for Inline Comments")
parts.append("CRITICAL: The `line` field in each violation "
"MUST fall within one of these ranges.")
parts.append("These are the only lines where GitHub "
"allows inline review comments.")
parts.append("If the code you want to flag is not on a "
"+ line in the diff, use the nearest + line "
"within the same hunk.")
parts.append("```")
parts.append(format_diff_line_ranges(diff_line_ranges))
parts.append("```")
parts.append("")
# 3. Image paths (if any)
if images:
parts.append("This PR includes screenshots/images. Use the "
"Read tool to view each image for visual "
"context about what the PR changes:")
for img in images:
parts.append(f"- {img['abs_path']} "
f"(from: {img['source']}, "
f"alt: \"{img['alt']}\")")
parts.append("")
# 4. Prior comments context
if prior_comments:
parts.append("## Prior Review Comments")
parts.append("")
parts.append(f"The bot's GitHub username is "
f"`{bot_username}`. Comments from this user "
"are the bot's own previous comments.")
parts.append("")
parts.append(prior_comments)
parts.append("")
# 5. The chunk content (rules) — varies per subagent
parts.append("Here are the best practice rules to check:")
parts.append("```markdown")
parts.append(chunk_content)
parts.append("```")
parts.append("")
# 6. Review rules
parts.append(_REVIEW_RULES)
parts.append("")
# 7. Best practice link requirement
parts.append(
_BEST_PRACTICE_LINK_REQUIREMENT.format(bp_link_base=BP_LINK_BASE))
parts.append("")
# 8. Prior comments re-review rules
if prior_comments:
parts.append(_PRIOR_COMMENTS_RULES)
parts.append("")
# 9. Systematic audit requirement
parts.append(_SYSTEMATIC_AUDIT_REQUIREMENT)
parts.append("")
# 10. Required output format
output_fmt = _REQUIRED_OUTPUT_FORMAT.format(
doc=doc,
chunk_num=chunk_index + 1,
total_chunks=total_chunks,
pr_number=pr_number,
pr_repo=PR_REPO,
title=pr_title,
)
parts.append(output_fmt)
# 11. Validation instructions (subagent validates its own findings)
if results_file and target_repo_path:
if base_ref and is_feature_branch(base_ref):
base_branch_validation_note = (
f"\n- Base branch: The source tree at `{{target_repo_path}}`"
f" reflects `{DEFAULT_BRANCH}`, not `{base_ref}`. Files or "
f"symbols added by `{base_ref}` will NOT be present here. "
f"Before claiming something doesn't exist, check: "
f"`gh api repos/{PR_REPO}/contents/<path>?ref={base_ref}`. "
f"If found, the violation is a false positive — drop it.")
else:
base_branch_validation_note = ""
parts.append("")
parts.append(
_VALIDATION_INSTRUCTIONS.format(
target_repo_path=target_repo_path,
results_file=results_file,
base_branch_validation_note=base_branch_validation_note,
))
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Process a single PR (for ThreadPoolExecutor)
# ---------------------------------------------------------------------------
def process_pr(pr, bot_username, org_members, work_dir):
"""Process a single PR.
Fetch diff, classify, comments, images, threads,
chunks. Writes prompt files to work_dir. Returns a
dict for the manifest or an error dict.
"""
pr_number = pr["number"]
pr_title = pr["title"]
head_sha = pr["headRefOid"]
author = pr["author"]
base_ref = pr.get("baseRefName", "")
has_approval = pr.get("hasApproval", False)
is_external = pr.get("isExternalContributor", False)
log(f" Processing PR #{pr_number}: {pr_title}")
try:
# a. Fetch diff
diff_text = fetch_diff(pr_number)
except Exception as e:
return None, {
"pr_number": pr_number,
"stage": "fetch_diff",
"error": str(e)
}
try:
# b. Classify files
file_flags = classify_files(diff_text)
except Exception as e:
return None, {
"pr_number": pr_number,
"stage": "classify_files",
"error": str(e)
}
try:
# c. Fetch prior comments
include_author = author if is_external else None
prior_comments, has_bot_comments = fetch_prior_comments(
pr_number, org_members, include_author=include_author)
except Exception as e:
prior_comments = None
has_bot_comments = False
log(f" WARNING: prior comments failed for #{pr_number}: {e}")
try:
# d. Extract images
images = extract_images(pr_number)
except Exception as e:
images = []
log(f" WARNING: image extraction failed for #{pr_number}: {e}")
try:
# e. Resolve addressed threads
thread_resolution = resolve_bot_threads(pr_number, bot_username)
except Exception as e:
thread_resolution = {
"resolved": 0,
"unresolved_bot_threads": 0,
"total_bot_threads": 0
}
log(f" WARNING: thread resolution failed for #{pr_number}: {e}")
try:
# f. Discover applicable best-practice docs
applicable_docs = discover_best_practices(file_flags)
except Exception as e:
applicable_docs = []
log(f" WARNING: discover best practices failed for #{pr_number}: {e}")
# g. Parse diff line ranges for subagent prompts
diff_line_ranges = parse_diff_line_ranges(diff_text)
# h+i. Chunk each doc, build subagent prompts, write to files
pr_work_dir = os.path.join(work_dir, f"pr_{pr_number}")
os.makedirs(pr_work_dir, exist_ok=True)
subagent_prompts = []
total_prompt_chars = 0
diff_chars = len(diff_text)
prior_comments_chars = len(prior_comments) if prior_comments else 0
for doc_info in applicable_docs:
try:
chunks = chunk_doc(doc_info["path"])
for chunk in chunks:
chunk_id = f"{doc_info['doc']}_{chunk['chunk_index']}"
prompt_file = os.path.join(pr_work_dir,
f"{chunk_id}_prompt.txt")
results_file = os.path.join(pr_work_dir,
f"{chunk_id}_results.json")
prompt = build_subagent_prompt(
pr_number,
pr_title,
diff_text,
images,
prior_comments,
bot_username,
chunk,
diff_line_ranges=diff_line_ranges,
results_file=results_file,
target_repo_path=TARGET_REPO_PATH,
base_ref=base_ref,
)
# Write prompt to file (not embedded in JSON)
with open(prompt_file, "w") as f:
f.write(prompt)
prompt_chars = len(prompt)
total_prompt_chars += prompt_chars
subagent_prompts.append({
"chunk_id": chunk_id,
"doc": doc_info["doc"],
"chunk_index": chunk["chunk_index"],
"total_chunks": chunk["total_chunks"],
"rule_count": chunk["rule_count"],
"headings": chunk["headings"],
"prompt_file": prompt_file,
"results_file": results_file,
"cost_estimate": {
"prompt_chars": prompt_chars,
"prompt_tokens_approx": prompt_chars // 4,
},
})
except Exception as e:
log(f" WARNING: chunking failed for {doc_info['doc']}: {e}")
# Lightweight manifest entry — no diff or prior_comments
pr_result = {
"number": pr_number,
"title": pr_title,
"headRefOid": head_sha,
"author": author,
"hasApproval": has_approval,
"isExternalContributor": is_external,
"has_bot_comments": has_bot_comments,
"images": images,
"thread_resolution": thread_resolution,
"subagent_prompts": subagent_prompts,
}
# Cost logging
log(f" COST PR #{pr_number}: diff={diff_chars:,} chars, "
f"prior_comments={prior_comments_chars:,} chars, "
f"{len(subagent_prompts)} chunks, "
f"total_prompt={total_prompt_chars:,} chars "
f"(~{total_prompt_chars // 4:,} tokens)")
log(f" Done PR #{pr_number}: {len(subagent_prompts)} subagent prompts")
return pr_result, None
# ---------------------------------------------------------------------------
# Process a single cached PR
# ---------------------------------------------------------------------------
def process_cached_pr(pr, bot_username):
"""Process a cached PR: resolve threads, check approval gate."""
pr_number = pr["number"]
pr_title = pr["title"]
head_sha = pr["headRefOid"]
log(f" Processing cached PR #{pr_number}: {pr_title}")
# Resolve threads
try:
thread_resolution = resolve_bot_threads(pr_number, bot_username)
except Exception as e:
thread_resolution = {
"resolved": 0,
"unresolved_bot_threads": 0,
"total_bot_threads": 0
}
log(f" WARNING: thread resolution failed for cached #{pr_number}: {e}"
)
# Check approval gate
try:
can_approve = check_can_approve(pr_number, bot_username)
except Exception as e:
can_approve = {"result": False, "reason": str(e)}
# If can approve, actually submit the APPROVE and update cache
approved = False
if can_approve["result"]:
log(f" Approving cached PR #{pr_number}")
approved = submit_approve(pr_number, head_sha)
if approved:
log(f" Approved PR #{pr_number}")
else:
log(f" WARNING: Failed to submit APPROVE for #{pr_number}")
return {
"number": pr_number,
"title": pr_title,
"headRefOid": head_sha,
"thread_resolution": thread_resolution,
"can_approve": can_approve,
"approved": approved,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
auto_mode, reviewer_priority, max_prs, fetch_args = parse_args()
# 1. Resolve bot username
log("Resolving bot username...")
bot_username = resolve_bot_username()
log(f"Bot username: {bot_username}")
# 2. Config already loaded at module level
# 3. Call fetch-prs logic
log("Fetching PRs...")
# Build argv for fetch-prs module's parse_args
fetch_argv = list(fetch_args)
if reviewer_priority:
fetch_argv.extend(["--reviewer-priority", bot_username])
if max_prs is not None:
fetch_argv.extend(["--max-prs", str(max_prs)])
# Temporarily override sys.argv for the fetch module
old_argv = sys.argv
sys.argv = ["fetch-prs.py"] + fetch_argv
mode, days, page, pr_number, state, _rp, _mp = _fp_mod.parse_args()
sys.argv = old_argv
raw_prs = _fp_mod.fetch_prs(mode, days, page, pr_number, state)
org_members = load_org_members()
if mode == "single":
to_review = raw_prs
cached_prs_raw = []
fetch_summary = {
"total_fetched": len(raw_prs),
"to_review": len(raw_prs),
"cached_with_possible_threads": 0,
"skipped_filtered": 0,
"skipped_cached": 0,
"skipped_approved": 0,
"skipped_external": 0,
"skipped_uplift": 0,
"skipped_max_prs": 0,
}
else:
cache = _fp_mod.load_cache()
(
to_review,
cached_prs_raw,
uplift_prs_raw,
skipped_filtered,
skipped_cached,
skipped_approved,
skipped_external,
skipped_uplift,
) = _fp_mod.filter_prs(
raw_prs,
mode,
days,
cache,
org_members,
reviewer_priority=bot_username if reviewer_priority else None,
)
# Update cache for uplift PRs so they are not re-fetched
for upr in uplift_prs_raw:
subprocess.run(
[
sys.executable,
os.path.join(_SCRIPT_DIR, "update-cache.py"),
str(upr["number"]),
upr["headRefOid"],
],
capture_output=True,
text=True,
timeout=10,
cwd=_REPO_DIR,
check=False,
)
# Sort by reviewer priority
if reviewer_priority:
to_review.sort(key=lambda p: 0 if _fp_mod.is_requested_reviewer(
p, bot_username) else 1)
cached_prs_raw.sort(key=lambda p: 0 if _fp_mod.
is_requested_reviewer(p, bot_username) else 1)
# Apply max-prs limit
skipped_max_prs = 0
if max_prs is not None and len(to_review) > max_prs:
skipped_max_prs = len(to_review) - max_prs
to_review = to_review[:max_prs]
fetch_summary = {
"total_fetched": len(raw_prs),
"to_review": len(to_review),
"cached_with_possible_threads": len(cached_prs_raw),
"skipped_filtered": skipped_filtered,
"skipped_cached": skipped_cached,
"skipped_approved": skipped_approved,
"skipped_external": skipped_external,
"skipped_uplift": skipped_uplift,
"skipped_max_prs": skipped_max_prs,
}
# Build PR entry dicts for processing
_rp_val = bot_username if reviewer_priority else None
def pr_entry(pr):
author = pr.get("author", {}).get("login", "unknown")
entry = {
"number": pr["number"],
"title": pr["title"],
"headRefOid": pr["headRefOid"],
"baseRefName": pr.get("baseRefName", ""),
"author": author,
"hasApproval": _fp_mod.has_any_approval(pr),
"isExternalContributor": bool(org_members
and author not in org_members),
}
return entry
prs_to_process = [pr_entry(p) for p in to_review]
cached_to_process = [pr_entry(p) for p in cached_prs_raw]
progress_lines = [
f"Found {len(prs_to_process)} PRs to review, "
f"{len(cached_to_process)} cached PRs to check threads.",
]
if fetch_summary["skipped_filtered"]:
progress_lines.append(
f"Skipped {fetch_summary['skipped_filtered']} PRs (filtered).")
if fetch_summary["skipped_approved"]:
progress_lines.append(f"Skipped {fetch_summary['skipped_approved']}"
" PRs (already approved).")
if fetch_summary["skipped_external"]:
progress_lines.append(f"Skipped {fetch_summary['skipped_external']}"
" PRs (external contributors).")
if fetch_summary.get("skipped_uplift"):
progress_lines.append(f"Skipped {fetch_summary['skipped_uplift']}"
" PRs (uplifts to version branches).")
log("\n".join(progress_lines))
# Create work directory for prompt/result files
work_dir = tempfile.mkdtemp(prefix="review-prs-")
log(f"Work directory: {work_dir}")
# 4. Process each PR in parallel
errors = []
processed_prs = []
if prs_to_process:
log(f"\nProcessing {len(prs_to_process)} PRs in parallel...")
with ThreadPoolExecutor(max_workers=5) as executor:
args = (bot_username, org_members, work_dir)
futures = {
executor.submit(process_pr, pr, *args): pr
for pr in prs_to_process
}
for future in as_completed(futures):
pr = futures[future]
try:
result, error = future.result()
if error:
errors.append(error)
if result:
processed_prs.append(result)
except Exception as e:
errors.append({
"pr_number": pr["number"],
"stage": "process_pr",
"error": str(e),
})
# Sort processed PRs to match original order
pr_order = {p["number"]: i for i, p in enumerate(prs_to_process)}
processed_prs.sort(key=lambda p: pr_order.get(p["number"], 999999))
# 5. Process cached PRs in parallel
processed_cached = []
if cached_to_process:
log(f"\nProcessing {len(cached_to_process)} cached PRs...")
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(process_cached_pr, pr, bot_username): pr
for pr in cached_to_process
}
for future in as_completed(futures):
pr = futures[future]
try:
result = future.result()
processed_cached.append(result)
except Exception as e:
errors.append({
"pr_number": pr["number"],
"stage": "process_cached_pr",
"error": str(e),
})
# Sort cached PRs to match original order
cached_order = {p["number"]: i for i, p in enumerate(cached_to_process)}
processed_cached.sort(key=lambda p: cached_order.get(p["number"], 999999))
# 6. Write manifest.json (lightweight — no diffs,
# no prompts, just file paths)
output = {
"bot_username": bot_username,
"pr_repo": PR_REPO,
"auto_mode": auto_mode,
"reviewer_priority": reviewer_priority,
"fetch_summary": fetch_summary,
"progress_lines": progress_lines,
"prs": processed_prs,
"cached_prs": processed_cached,
"errors": errors,
}
manifest_path = os.path.join(work_dir, "manifest.json")
with open(manifest_path, "w") as f:
json.dump(output, f, indent=2)
total_prompts = sum(
len(p.get("subagent_prompts", [])) for p in processed_prs)
total_prompt_chars = sum(
sp.get("cost_estimate", {}).get("prompt_chars", 0)
for p in processed_prs for sp in p.get("subagent_prompts", []))
total_prompt_tokens = total_prompt_chars // 4
# Cost summary
log(f"\n{'=' * 60}")
log("COST SUMMARY")
log(f"{'=' * 60}")
log(f"PRs to review: {len(processed_prs)}")
log(f"Total subagent prompts: {total_prompts}")
log(f"Total prompt size: {total_prompt_chars:,} "
f"chars (~{total_prompt_tokens:,} tokens)")
if total_prompts > 0:
avg_chars = total_prompt_chars // total_prompts
log(f"Average prompt size: {avg_chars:,} "
f"chars (~{avg_chars // 4:,} tokens)")
log(f"Cached PRs processed: {len(processed_cached)}")
log(f"Errors: {len(errors)}")
# Per-PR breakdown
for pr in processed_prs:
pr_chars = sum(
sp.get("cost_estimate", {}).get("prompt_chars", 0)
for sp in pr.get("subagent_prompts", []))
chunks = len(pr.get('subagent_prompts', []))
log(f" PR #{pr['number']}: {chunks} chunks, "
f"{pr_chars:,} chars (~{pr_chars // 4:,} tokens)")
log(f"{'=' * 60}")
log(f"\nDone. {len(processed_prs)} PRs processed, "
f"{total_prompts} total subagent prompts, "
f"{len(processed_cached)} cached PRs processed, "
f"{len(errors)} errors.")
# Output just the work_dir path to stdout (tiny — the LLM only needs this)
print(json.dumps({"work_dir": work_dir, "manifest": manifest_path}))
if __name__ == "__main__":
main()
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Update the review-prs cache with a PR's HEAD SHA after review.
Also updates _last_run timestamp so the next fetch-prs.py run uses it
as the cutoff instead of a fixed N-day window. This prevents gaps if a
cron run is missed.
Usage:
update-cache.py <pr_number> <head_ref_oid> # Update SHA only
update-cache.py <pr_number> <head_ref_oid> --approve
# Update SHA + mark approved
"""
import json
import os
import sys
from datetime import datetime, timezone
_script_dir = os.path.dirname(os.path.abspath(__file__))
_repo_dir = os.path.normpath(os.path.join(_script_dir, "..", "..", ".."))
PR_REPO = "brave/brave-core"
args = [a for a in sys.argv[1:] if not a.startswith("--")]
flags = [a for a in sys.argv[1:] if a.startswith("--")]
if len(args) != 2:
print(f"Usage: {sys.argv[0]} <pr_number> <head_ref_oid> [--approve]",
file=sys.stderr)
sys.exit(1)
pr_number = args[0]
head_ref_oid = args[1]
approve = "--approve" in flags
cache_path = os.path.join(_repo_dir, ".ignore", "review-prs-cache.json")
try:
with open(cache_path) as f:
cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
cache = {}
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
cache[pr_number] = head_ref_oid
cache["_last_run"] = datetime.now(timezone.utc).isoformat()
if approve:
approved = set(cache.get("_approved", []))
approved.add(pr_number)
cache["_approved"] = sorted(approved)
with open(cache_path, "w") as f:
json.dump(cache, f, indent=2)
f.write("\n")
status = "approved + cached" if approve else "cached"
pr_url = (f"https://github.com/{PR_REPO}/pull/{pr_number}")
print(f"Cache updated ({status}): "
f"[PR #{pr_number}]({pr_url}) -> {head_ref_oid}")