Skip to content

feat(hovercard): match GitHub native issue row hover UI #33

feat(hovercard): match GitHub native issue row hover UI

feat(hovercard): match GitHub native issue row hover UI #33

Workflow file for this run

name: PR Review
on:
pull_request:
types: [opened, ready_for_review]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout PR head
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set review directory
run: |
set -euo pipefail
review_dir="$RUNNER_TEMP/cursor-pr-review"
mkdir -p "$review_dir"
echo "REVIEW_DIR=$review_dir" >> "$GITHUB_ENV"
- name: Install Cursor CLI
run: |
set -euo pipefail
installer="$(mktemp)"
trap 'rm -f "$installer"' EXIT
curl https://cursor.com/install -fsSL -o "$installer"
if [ ! -s "$installer" ]; then
echo "Cursor installer download was empty."
exit 1
fi
bash "$installer"
for bin_dir in "$HOME/.cursor/bin" "$HOME/.local/bin"; do
if [ -d "$bin_dir" ]; then
echo "$bin_dir" >> "$GITHUB_PATH"
export PATH="$bin_dir:$PATH"
fi
done
agent --version
- name: Prepare review context
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import pathlib
import re
import subprocess
import textwrap
review_dir = pathlib.Path(os.environ["REVIEW_DIR"])
review_dir.mkdir(parents=True, exist_ok=True)
event = json.loads(pathlib.Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
pull_request = event["pull_request"]
base_sha = pull_request["base"]["sha"]
head_sha = pull_request["head"]["sha"]
def git_output(*args: str) -> str:
return subprocess.run(
["git", *args],
check=True,
capture_output=True,
text=True,
).stdout
def write_text(path: pathlib.Path, text: str) -> None:
path.write_text(text if text.endswith("\n") else text + "\n")
def write_json(path: pathlib.Path, payload) -> None:
path.write_text(json.dumps(payload, indent=2) + "\n")
diff_text = git_output("diff", "--no-color", f"{base_sha}...{head_sha}")
changed_files = git_output("diff", "--name-only", f"{base_sha}...{head_sha}")
metadata = {
"number": pull_request["number"],
"title": pull_request.get("title") or "",
"body": pull_request.get("body") or "",
"html_url": pull_request.get("html_url") or "",
"base": {
"ref": pull_request["base"]["ref"],
"sha": base_sha,
},
"head": {
"ref": pull_request["head"]["ref"],
"sha": head_sha,
},
}
target_lines = {}
current_file = None
new_line = None
for line in diff_text.splitlines():
if line.startswith("diff --git "):
current_file = None
new_line = None
continue
if line.startswith("+++ "):
if line == "+++ /dev/null":
current_file = None
elif line.startswith("+++ b/"):
current_file = line[6:]
target_lines.setdefault(current_file, set())
else:
current_file = None
continue
if line.startswith("@@"):
match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
new_line = int(match.group(1)) if match else None
continue
if current_file is None or new_line is None:
continue
if line.startswith("+") and not line.startswith("+++"):
target_lines[current_file].add(new_line)
new_line += 1
elif line.startswith("-") and not line.startswith("---"):
continue
else:
new_line += 1
comment_targets = {
"files": [
{
"path": path,
"added_lines": sorted(lines),
}
for path, lines in sorted(target_lines.items())
if lines
]
}
prompt = textwrap.dedent(
f"""\
Review the submitted GitHub pull request using the checked-out repository and the provided context files.
Repository root: {pathlib.Path.cwd()}
Context files:
- {review_dir / 'pr-metadata.json'}
- {review_dir / 'changed-files.txt'}
- {review_dir / 'pr-diff.txt'}
- {review_dir / 'comment-targets.json'}
Return strict JSON only. Do not wrap it in markdown fences. Do not add any text before or after the JSON.
JSON schema:
{{
"summary": "Short markdown summary. Use 'No actionable issues found.' when appropriate.",
"comments": [
{{
"path": "relative/path.ts",
"line": 123,
"body": "One concise, actionable review comment for that changed line."
}}
]
}}
Review rules:
- Review only the submitted PR changes plus any directly relevant surrounding repository context.
- Report only high-confidence bugs, security issues, correctness problems, or major maintainability issues.
- Do not report formatting, naming, style-only nits, or speculative concerns.
- Use at most 6 inline comments.
- Each inline comment must target a changed added line from comment-targets.json.
- line must be the new-file line number.
- body must be concise, specific, and actionable.
- If there are no actionable issues, return {{"summary":"No actionable issues found.","comments":[]}}.
"""
)
write_text(review_dir / "pr-diff.txt", diff_text)
write_text(review_dir / "changed-files.txt", changed_files)
write_json(review_dir / "pr-metadata.json", metadata)
write_json(review_dir / "comment-targets.json", comment_targets)
write_text(review_dir / "prompt.txt", prompt)
PY
- name: Run Cursor PR review
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
if ! timeout 900 agent -p --trust --model auto --output-format json "$(cat "$REVIEW_DIR/prompt.txt")" > "$REVIEW_DIR/cursor-output.json" 2> "$REVIEW_DIR/cursor-error.log"; then
cat "$REVIEW_DIR/cursor-error.log" >&2 || true
exit 1
fi
if [ ! -s "$REVIEW_DIR/cursor-output.json" ]; then
echo "Cursor review produced no JSON output." >&2
cat "$REVIEW_DIR/cursor-error.log" >&2 || true
exit 1
fi
- name: Normalize and post review feedback
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
import urllib.error
import urllib.request
review_dir = pathlib.Path(os.environ["REVIEW_DIR"])
event = json.loads(pathlib.Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
pull_request = event["pull_request"]
repo = os.environ["GITHUB_REPOSITORY"]
pr_number = pull_request["number"]
head_sha = pull_request["head"]["sha"]
token = os.environ["GITHUB_TOKEN"]
def strip_code_fences(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return text.strip()
def extract_structured_result(text: str):
text = strip_code_fences(text)
decoder = json.JSONDecoder()
for start in range(len(text)):
if text[start] != "{":
continue
try:
result, _ = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
continue
if isinstance(result, dict):
return result
return None
def normalize_path(value: str) -> str:
value = (value or "").strip()
if value.startswith("./"):
value = value[2:]
if value.startswith("b/"):
value = value[2:]
return value
def normalize_summary(value) -> str:
if isinstance(value, list):
lines = []
for item in value:
item_text = str(item).strip()
if item_text:
prefix = "- " if not item_text.startswith(("- ", "* ")) else ""
lines.append(f"{prefix}{item_text}")
return "\n".join(lines).strip()
if isinstance(value, str):
return value.strip()
if value is None:
return ""
return str(value).strip()
def shorten(text: str, limit: int) -> str:
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def post(endpoint: str, payload: dict, *, allow_422: bool = False):
request = urllib.request.Request(
f"https://api.github.com{endpoint}",
data=json.dumps(payload).encode(),
method="POST",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
error_body = exc.read().decode()
if allow_422 and exc.code == 422:
print(f"Skipping inline comment because GitHub rejected it: {error_body}", file=sys.stderr)
return None
raise RuntimeError(f"GitHub API request failed ({exc.code}): {error_body}") from exc
outer = json.loads((review_dir / "cursor-output.json").read_text())
assistant_text = str(outer.get("result") or "").strip()
structured = extract_structured_result(assistant_text)
comment_targets = json.loads((review_dir / "comment-targets.json").read_text())
valid_targets = {
entry["path"]: set(entry["added_lines"])
for entry in comment_targets.get("files", [])
}
raw_comments = []
summary = ""
if structured:
summary = normalize_summary(structured.get("summary"))
raw_comments = structured.get("comments") or []
else:
summary = assistant_text or ""
filtered_comments = []
seen = set()
for candidate in raw_comments:
if not isinstance(candidate, dict):
continue
path = normalize_path(str(candidate.get("path") or ""))
body = shorten(str(candidate.get("body") or ""), 500)
try:
line = int(candidate.get("line"))
except (TypeError, ValueError):
continue
if not path or not body or line <= 0:
continue
if line not in valid_targets.get(path, set()):
continue
key = (path, line)
if key in seen:
continue
seen.add(key)
filtered_comments.append(
{
"path": path,
"line": line,
"body": body,
}
)
if len(filtered_comments) >= 6:
break
summary = shorten(summary, 4000)
if not summary:
if filtered_comments:
summary = f"Found {len(filtered_comments)} actionable issue(s) on changed lines."
else:
summary = "No actionable issues found."
review_body_lines = [
"## Cursor auto review",
"",
"No actionable issues found on changed lines."
if not filtered_comments
else f"Found {len(filtered_comments)} actionable issue(s) on changed lines.",
"",
summary,
"",
"_Generated automatically when this PR was submitted using Cursor CLI with `--model auto`._",
]
review_body = "\n".join(line for line in review_body_lines if line is not None).strip()
review_request = {
"event": "COMMENT",
"body": review_body,
}
review_comments = [
{
"body": comment["body"],
"path": comment["path"],
"line": comment["line"],
"side": "RIGHT",
}
for comment in filtered_comments
]
posted_inline_comments = 0
if review_comments:
review_request["comments"] = review_comments
review_response = post(
f"/repos/{repo}/pulls/{pr_number}/reviews",
review_request,
allow_422=True,
)
if review_response is not None:
posted_inline_comments = len(review_comments)
else:
print(
"Combined review creation was rejected; falling back to summary review and best-effort inline comments.",
file=sys.stderr,
)
post(
f"/repos/{repo}/pulls/{pr_number}/reviews",
{
"event": "COMMENT",
"body": review_body,
},
)
for comment in review_comments:
payload = {
"body": comment["body"],
"commit_id": head_sha,
"path": comment["path"],
"line": comment["line"],
"side": "RIGHT",
}
if post(f"/repos/{repo}/pulls/{pr_number}/comments", payload, allow_422=True) is not None:
posted_inline_comments += 1
else:
post(f"/repos/{repo}/pulls/{pr_number}/reviews", review_request)
print(f"Posted {posted_inline_comments} inline comment(s).")
PY