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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
101 changes: 101 additions & 0 deletions .github/cursor-review/extract-findings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Parse raw cursor-agent output into a normalized findings record.

Used by per-cell matrix steps. Each cell calls this to convert the model's
raw stdout into a JSON file the consolidate step can ingest. The output is
always structured — even on parse failures or empty output — so the
consolidate step has a uniform input.

Output shape:
{
"model": str,
"review_type": str,
"status": "ok" | "empty" | "error" | "parse_error",
"findings": [{"file": str, "line": int, "side": "RIGHT", "body": str}, ...],
"error": str # only when status != "ok"
}
"""

import argparse
import json
import re


def parse_json_findings(raw_text: str):
"""Extract a JSON array from raw model output, tolerating surrounding prose.

Returns the parsed value, or None if no JSON array could be located.
"""
text = raw_text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass

fenced = re.search(r"```(?:json)?\s*\n(.*?)```", text, re.DOTALL)
if fenced:
try:
return json.loads(fenced.group(1).strip())
except json.JSONDecodeError:
pass

start = text.find("[")
end = text.rfind("]")
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
pass

return None


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--raw", required=True, help="Path to raw cursor-agent output")
parser.add_argument("--out", required=True, help="Path to write the findings JSON file")
parser.add_argument("--model", required=True)
parser.add_argument("--review-type", required=True)
args = parser.parse_args()

record = {"model": args.model, "review_type": args.review_type}

try:
with open(args.raw, encoding="utf-8") as f:
raw = f.read()
except (OSError, UnicodeDecodeError) as e:
record.update(status="error", error=f"Could not read raw output: {e}", findings=[])
with open(args.out, "w", encoding="utf-8") as f:
json.dump(record, f)
return

if not raw.strip():
record.update(status="empty", error="Cursor agent produced empty output.", findings=[])
with open(args.out, "w", encoding="utf-8") as f:
json.dump(record, f)
return

findings = parse_json_findings(raw)

if findings is None:
# Truncate raw so artifacts stay small even on chatty parse failures.
record.update(
status="parse_error",
error=f"Could not parse JSON findings from output. First 500 chars:\n{raw[:500]}",
findings=[],
)
elif not isinstance(findings, list):
record.update(
status="parse_error",
error=f"Output parsed but is not an array (got {type(findings).__name__}).",
findings=[],
)
else:
record.update(status="ok", findings=findings)

with open(args.out, "w", encoding="utf-8") as f:
json.dump(record, f)


if __name__ == "__main__":
main()
297 changes: 297 additions & 0 deletions .github/cursor-review/post-review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Post a single consolidated cursor-review to a GitHub PR.

The consolidate step produces one findings file (output of the judge call,
augmented with panel metadata). This script reads that file and posts ONE
PR review with line-anchored inline comments.

Findings file shape:
{
"findings": [
{"file": str, "line": int, "side": "RIGHT", "severity": str, "body": str},
...
],
"panel": [
{"model": str, "review_type": str, "status": "ok"|"empty"|"error"|"parse_error"},
...
]
}

Falls back to a body-only review (no inline anchors) if GitHub rejects the
inline payload — typical cause is line numbers that don't match the diff.
"""

import argparse
import json
import subprocess
import sys

# Severity scale, ordered most → least urgent. Drives sort order, the inline
# comment prefix, and the summary table. The judge is instructed to emit one
# of these strings per finding (see prompt-judge.md); anything missing or
# unrecognized falls back to DEFAULT_SEVERITY so a malformed value can never
# drop a finding — it just lands in the middle bucket.
SEVERITY_ORDER = ["critical", "high", "medium", "low", "nit"]
SEVERITY_EMOJI = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "🟢",
"nit": "⚪",
}
SEVERITY_LABEL = {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low",
"nit": "Nit",
}
DEFAULT_SEVERITY = "medium"


def normalize_severity(value) -> str:
"""Coerce a model-supplied severity into one of SEVERITY_ORDER.

Tolerant by design: unknown, missing, or non-string values become
DEFAULT_SEVERITY rather than dropping the finding.
"""
if not isinstance(value, str):
return DEFAULT_SEVERITY
candidate = value.strip().lower()
return candidate if candidate in SEVERITY_EMOJI else DEFAULT_SEVERITY


def severity_rank(severity: str) -> int:
try:
return SEVERITY_ORDER.index(severity)
except ValueError:
return len(SEVERITY_ORDER)


def build_severity_summary(enriched: list[dict]) -> str:
"""Render a CodeRabbit-style severity breakdown table, highest first.

Only severities that actually occur get a row, so a PR with three nits
doesn't carry four empty rows of ceremony.
"""
counts: dict[str, int] = {}
for item in enriched:
counts[item["severity"]] = counts.get(item["severity"], 0) + 1
rows = [
f"| {SEVERITY_EMOJI[sev]} {SEVERITY_LABEL[sev]} | {counts[sev]} |"
for sev in SEVERITY_ORDER
if counts.get(sev)
]
if not rows:
return ""
return "| Severity | Count |\n| --- | --- |\n" + "\n".join(rows)


def neutralize_mentions(text: str) -> str:
"""Insert ZWSP after each `@` so model output can't trigger GitHub mentions."""
return str(text).replace("@", "@\u200B")


def gh_post_review(repo: str, pr_number: str, payload: str) -> subprocess.CompletedProcess:
return subprocess.run(
[
"gh",
"api",
"--method",
"POST",
f"/repos/{repo}/pulls/{pr_number}/reviews",
"--input",
"-",
],
input=payload,
text=True,
capture_output=True,
)


def build_panel_summary(panel: list[dict]) -> str:
if not panel:
return ""
ok = sum(1 for c in panel if c.get("status") == "ok")
failed = [c for c in panel if c.get("status") != "ok"]
parts = [f"_Panel: {ok}/{len(panel)} reviewers contributed findings._"]
if failed:
names = ", ".join(
f"{c.get('model','?')}:{c.get('review_type','?')} ({c.get('status','?')})"
for c in failed
)
parts.append(f"_Reviewers that did not contribute: {names}_")
return "\n\n".join(parts)


def normalize_comments(findings: list[dict]) -> list[dict]:
"""Build sorted, severity-tagged inline comments from raw judge findings.

Returns a list of {"severity": str, "comment": dict} entries sorted most
→ least urgent. The nested `comment` is the GitHub review-comment payload
(path/line/side/body) with the severity badge prefixed into the body;
severity is kept alongside (not inside) so the summary table can count it
without leaking an unknown key into the GitHub API request.
"""
enriched = []
for finding in findings:
if not isinstance(finding, dict):
print(f"Skipping non-dict finding: {finding!r}", file=sys.stderr)
continue
path = finding.get("file", "")
line = finding.get("line")
body = finding.get("body", "")
if not path or not line or not body:
continue
try:
line_int = int(line)
except (TypeError, ValueError):
print(f"Skipping non-integer line {line!r} for {path}", file=sys.stderr)
continue
if line_int <= 0:
print(f"Skipping non-positive line {line_int} for {path}", file=sys.stderr)
continue
severity = normalize_severity(finding.get("severity"))
badge = f"{SEVERITY_EMOJI[severity]} **{SEVERITY_LABEL[severity]}** — "
enriched.append(
{
"severity": severity,
"comment": {
"path": path,
"line": line_int,
"side": "RIGHT",
"body": badge + neutralize_mentions(body),
},
}
)
enriched.sort(key=lambda item: severity_rank(item["severity"]))
return enriched


def post_error_review(repo, pr_number, commit_sha, header, error_message):
safe = neutralize_mentions(error_message)
payload = json.dumps(
{
"body": (
f"{header}\n\n⚠️ **Review failed**\n\n```\n{safe}\n```\n\n"
"Re-trigger by removing and re-adding the `cursor-review` label."
),
"event": "COMMENT",
"commit_id": commit_sha,
}
)
result = gh_post_review(repo, pr_number, payload)
if result.returncode != 0:
print(f"Error-review POST failed: {result.stderr}", file=sys.stderr)
raise SystemExit(1)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--findings", required=True, help="Path to consolidated findings JSON")
parser.add_argument("--pr-number", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--commit-sha", required=True)
parser.add_argument("--triggered-by", default=None)
parser.add_argument("--error-message", default=None, help="If set, post an error review with this message")
args = parser.parse_args()

attribution = f"\n\n_Triggered by @{args.triggered_by}._" if args.triggered_by else ""
header = f"## 🔍 Cursor Review — Consolidated panel{attribution}"

if args.error_message:
post_error_review(args.repo, args.pr_number, args.commit_sha, header, args.error_message)
return

try:
with open(args.findings, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
post_error_review(
args.repo,
args.pr_number,
args.commit_sha,
header,
f"Could not load findings file: {e}",
)
return

findings = data.get("findings", []) or []
panel = data.get("panel", []) or []
panel_summary = build_panel_summary(panel)

if not findings:
# Distinguish two cases that both produce zero findings:
# 1. Panel ran, judge picked nothing → genuinely no high-signal issues.
# 2. Every panel cell errored → judge was skipped, no judging happened.
# Headlining (1) and (2) the same way ("No high-signal findings") is
# misleading on (2), so check the panel metadata explicitly.
all_failed = bool(panel) and all(c.get("status") != "ok" for c in panel)
if all_failed:
body_text = (
f"{header}\n\n⚠️ **Panel did not produce any findings.**\n\n"
"Every reviewer in the matrix failed to contribute — see the "
"panel summary for which cells errored, and the run logs for "
"the underlying cause."
)
else:
body_text = f"{header}\n\n✅ No high-signal findings."
if panel_summary:
body_text += f"\n\n{panel_summary}"
payload = json.dumps(
{"body": body_text, "event": "COMMENT", "commit_id": args.commit_sha}
)
result = gh_post_review(args.repo, args.pr_number, payload)
if result.returncode != 0:
print(f"No-findings review POST failed: {result.stderr}", file=sys.stderr)
raise SystemExit(1)
return

enriched = normalize_comments(findings)
comments = [item["comment"] for item in enriched]

review_body = f"{header}\n\nFound **{len(comments)}** finding(s)."
severity_summary = build_severity_summary(enriched)
if severity_summary:
review_body += f"\n\n{severity_summary}"
if panel_summary:
review_body += f"\n\n{panel_summary}"
if not comments and findings:
review_body += "\n\n_(All findings had invalid file/line references and were dropped.)_"

payload = json.dumps(
{
"body": review_body,
"event": "COMMENT",
"commit_id": args.commit_sha,
"comments": comments,
}
)

result = gh_post_review(args.repo, args.pr_number, payload)

if result.returncode != 0:
print(f"Review POST failed: {result.stderr}", file=sys.stderr)
# Fallback: same body without inline anchors. Typical cause is line
# numbers that fall outside the diff context — often the model picked
# a line near the change but not on the change.
fallback_body = review_body + "\n\n---\n\n"
for c in comments:
fallback_body += f"**`{c['path']}:{c['line']}`** — {c['body']}\n\n"
fallback_body += "\n_(Inline comments could not be anchored to the diff; listed above instead.)_"

fallback_payload = json.dumps(
{
"body": fallback_body,
"event": "COMMENT",
"commit_id": args.commit_sha,
}
)
fallback_result = gh_post_review(args.repo, args.pr_number, fallback_payload)
if fallback_result.returncode != 0:
print(f"Fallback review POST also failed: {fallback_result.stderr}", file=sys.stderr)
raise SystemExit(1)


if __name__ == "__main__":
main()
Loading