fix(bulk): route Mark unlock to bulkUnlock #39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |