-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cursor-review): optional blocking gate for unresolved findings (BE-1891) #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| #!/usr/bin/env python3 | ||
| """Blocking gate: fail when a PR has unresolved cursor-review finding threads. | ||
|
|
||
| Used by cursor-review.yml when the caller opts in with `blocking: true`. Queries | ||
| the PR's review threads (GraphQL) and exits non-zero when any cursor-review | ||
| finding thread is still unresolved — turning the check red so a required-status- | ||
| check configuration blocks the merge until the threads are addressed. With | ||
| `blocking: false` (the default) this script is never run and the review stays | ||
| purely advisory. | ||
|
|
||
| Identifying a cursor-review thread | ||
| ---------------------------------- | ||
| A review thread counts as a cursor-review finding thread when its originating | ||
| review's body starts with ``CONSOLIDATED_MARKER`` — the exact discriminator the | ||
| workflow's own dup-check uses to recognize its consolidated reviews. This was | ||
| chosen over matching the posting identity (e.g. ``cloud-code-bot`` / | ||
| ``github-actions[bot]``) on purpose: | ||
|
|
||
| * It is identity-independent. The consolidated review posts as | ||
| ``github-actions[bot]`` by default, or under a dedicated bot App when the | ||
| caller sets ``bot_app_id`` — the gate must not have to resolve a bot login | ||
| that varies per caller. | ||
| * It is the canonical cursor-review signal already used elsewhere in the | ||
| workflow, so there is one source of truth for "is this our review?". | ||
|
|
||
| Only the consolidated review that carries inline findings creates review | ||
| threads; the "no findings", error, and inline-anchoring-fallback reviews are | ||
| body-only and create none. So a thread existing here already means a real | ||
| finding was anchored to a line. | ||
|
|
||
| Outdated threads | ||
| ---------------- | ||
| Threads whose hunk has changed since the finding was posted (``isOutdated``) | ||
| are NOT counted. A re-review on a new commit re-posts anything still wrong as a | ||
| fresh, non-outdated thread, so blocking on outdated ones would force resolution | ||
| of superseded findings. Resolving the live threads — or pushing a fix and | ||
| re-triggering the review — is what turns the gate green, which makes it | ||
| idempotent on re-run. | ||
| """ | ||
|
|
||
| import argparse | ||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
|
|
||
| CONSOLIDATED_MARKER = "## 🔍 Cursor Review — Consolidated panel" | ||
|
|
||
| QUERY = """ | ||
| query($owner: String!, $name: String!, $pr: Int!, $cursor: String) { | ||
| repository(owner: $owner, name: $name) { | ||
| pullRequest(number: $pr) { | ||
| reviewThreads(first: 100, after: $cursor) { | ||
| pageInfo { hasNextPage endCursor } | ||
| nodes { | ||
| isResolved | ||
| isOutdated | ||
| comments(first: 1) { | ||
| nodes { | ||
| author { login } | ||
| pullRequestReview { body } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| """ | ||
|
|
||
|
|
||
| def run_graphql(owner: str, name: str, pr: int, cursor): | ||
| """Run one page of the reviewThreads query via the gh CLI.""" | ||
| args = [ | ||
| "gh", "api", "graphql", | ||
| "-f", f"query={QUERY}", | ||
| "-f", f"owner={owner}", | ||
| "-f", f"name={name}", | ||
| "-F", f"pr={pr}", | ||
| ] | ||
| # gh's -F coerces the literal `null` to a JSON null, which GraphQL reads as | ||
| # "from the start"; subsequent pages pass the opaque string cursor via -f. | ||
| args += ["-F", "cursor=null"] if cursor is None else ["-f", f"cursor={cursor}"] | ||
|
|
||
| result = subprocess.run(args, capture_output=True, text=True) | ||
| if result.returncode != 0: | ||
| # A query failure must not silently pass the gate — exit 2 (distinct | ||
| # from the "found unresolved" exit 1) so the check fails loudly. | ||
| print(f"GraphQL query failed: {result.stderr.strip()}", file=sys.stderr) | ||
| raise SystemExit(2) | ||
| return json.loads(result.stdout) | ||
|
|
||
|
|
||
| def is_cursor_thread(thread: dict) -> bool: | ||
| """True when the thread's originating review is a cursor-review consolidation.""" | ||
| comments = (thread.get("comments") or {}).get("nodes") or [] | ||
| if not comments: | ||
| return False | ||
| review = comments[0].get("pullRequestReview") or {} | ||
| body = review.get("body") or "" | ||
| return body.startswith(CONSOLIDATED_MARKER) | ||
|
|
||
|
|
||
| def collect_unresolved(owner: str, name: str, pr: int) -> int: | ||
| """Count live (non-outdated, unresolved) cursor-review finding threads.""" | ||
| count = 0 | ||
| cursor = None | ||
| while True: | ||
| data = run_graphql(owner, name, pr, cursor) | ||
| threads = data["data"]["repository"]["pullRequest"]["reviewThreads"] | ||
| for thread in threads["nodes"]: | ||
| if not is_cursor_thread(thread): | ||
| continue | ||
| if thread.get("isResolved") or thread.get("isOutdated"): | ||
| continue | ||
| count += 1 | ||
| page = threads["pageInfo"] | ||
| if not page["hasNextPage"]: | ||
| return count | ||
| cursor = page["endCursor"] | ||
|
|
||
|
|
||
| def emit(text: str) -> None: | ||
| print(text) | ||
| summary_path = os.environ.get("GITHUB_STEP_SUMMARY") | ||
| if summary_path: | ||
| with open(summary_path, "a", encoding="utf-8") as f: | ||
| f.write(text + "\n") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--repo", required=True, help="owner/name of the PR repo") | ||
| parser.add_argument("--pr-number", required=True, type=int) | ||
| args = parser.parse_args() | ||
|
|
||
| owner, _, name = args.repo.partition("/") | ||
| if not owner or not name: | ||
| print(f"--repo must be owner/name, got {args.repo!r}", file=sys.stderr) | ||
| raise SystemExit(2) | ||
|
|
||
| count = collect_unresolved(owner, name, args.pr_number) | ||
|
|
||
| if count: | ||
| emit( | ||
| f"❌ **Blocking gate: {count} unresolved cursor-review finding " | ||
| f"thread(s).**\n\nResolve each open cursor-review thread on this PR " | ||
| "(or push a fix and re-trigger the review), then re-run this check." | ||
| ) | ||
| raise SystemExit(1) | ||
|
|
||
| emit("✅ **Blocking gate: no unresolved cursor-review finding threads.**") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,11 @@ name: Cursor Review (reusable) | |
| # # a distinct, queryable identity instead of github-actions[bot]. Supply | ||
| # # your App's id + private key (App IDs aren't secret, so id is an input). | ||
| # bot_app_id: ${{ vars.REVIEW_BOT_APP_ID }} | ||
| # # Optional: make the review blocking. true → the "Blocking gate" check | ||
| # # goes red while any cursor-review finding thread is unresolved. To | ||
| # # actually block merge you must ALSO mark "Blocking gate" as a required | ||
| # # status check in this repo's branch-protection settings (see README). | ||
| # blocking: true | ||
| # secrets: | ||
| # CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | ||
| # SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | ||
|
|
@@ -107,6 +112,20 @@ on: | |
| type: string | ||
| required: false | ||
| default: '' | ||
| blocking: | ||
| description: >- | ||
| Opt-in merge gate. false (default) → the review is advisory, exactly as | ||
| before: it posts findings but never fails the check, so no caller's | ||
| behavior changes until it opts in. true → a final "Blocking gate" job | ||
| fails (red check) when the PR has unresolved cursor-review finding | ||
| threads, and passes once they are all resolved (idempotent on re-run). | ||
| NOTE: this workflow cannot set branch protection. To actually block the | ||
| merge you must ALSO mark the "Blocking gate" check as a required status | ||
| check in the caller repo's branch-protection / ruleset settings — until | ||
| you do, the red check is visible but non-blocking. See the README. | ||
| type: boolean | ||
| required: false | ||
| default: false | ||
| secrets: | ||
| CURSOR_API_KEY: | ||
| description: Cursor API key for cursor-agent (the panel + judge models bill through it). | ||
|
|
@@ -633,6 +652,45 @@ jobs: | |
| --triggered-by "$TRIGGERED_BY" | ||
| fi | ||
|
|
||
| blocking-gate: | ||
| # Opt-in merge gate (inputs.blocking). Fails the check when the PR has | ||
| # unresolved cursor-review finding threads, so a required-status-check | ||
| # config can block the merge until they're addressed. With blocking:false | ||
| # (default) this job is skipped entirely — the review stays advisory and | ||
| # the existing path is unchanged. | ||
| # | ||
| # Runs whenever the review is genuinely triggered (should_run), regardless | ||
| # of already_reviewed / within_cap: on a resolve→re-trigger of an unchanged | ||
| # commit the panel is skipped (already_reviewed) but this gate still | ||
| # re-queries the live thread state and goes green once the threads are | ||
| # resolved. `needs: consolidate` (with always()) orders it after the review | ||
| # is posted on a fresh run, and lets it run even when consolidate is skipped. | ||
| name: Blocking gate | ||
| needs: [gate, consolidate] | ||
| if: ${{ always() && inputs.blocking && needs.gate.outputs.should_run == 'true' }} | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| steps: | ||
| - name: Load cursor-review assets | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| repository: Comfy-Org/github-workflows | ||
| ref: ${{ inputs.workflows_ref }} | ||
| path: _cursor_review_assets | ||
| persist-credentials: false | ||
|
Comment on lines
+676
to
+682
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Pin 🧰 Tools🪛 zizmor (1.26.1)[error] 677-677: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy) (unpinned-uses) 🤖 Prompt for AI Agents |
||
|
|
||
| - name: Fail on unresolved cursor-review threads | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| REPO: ${{ github.repository }} | ||
| run: | | ||
| python3 "$CURSOR_REVIEW_ASSETS/gate-unresolved.py" \ | ||
| --repo "$REPO" \ | ||
| --pr-number "$PR_NUMBER" | ||
|
|
||
| notify-start: | ||
| name: Notify start | ||
| needs: [gate, diff-size] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Don't let a green gate mask a failed fresh review.
Because of
always(), this job still runs after a failedconsolidateand can pass if no review threads were posted. Since the docs tell callers to require only Blocking gate, that lets a broken fresh review slip through with a green check. Adddiff-sizetoneedsand fail here whenever a fresh, within-cap run was supposed to finishconsolidatebutneeds.consolidate.result != 'success'.Suggested guard
📝 Committable suggestion
🤖 Prompt for AI Agents