Skip to content

feat(cursor-review): weekly catalog-drift check for the panel pins (BE-4819) - #87

Open
mattmillerai wants to merge 3 commits into
mainfrom
matt/be-4819-cursor-review-catalog-drift
Open

feat(cursor-review): weekly catalog-drift check for the panel pins (BE-4819)#87
mattmillerai wants to merge 3 commits into
mainfrom
matt/be-4819-cursor-review-catalog-drift

Conversation

@mattmillerai

Copy link
Copy Markdown
Contributor

ELI-5

The review panel pins four model ids (plus a judge). Cursor keeps swapping models under us: a pinned id gets delisted (every consumer PR then fails the review preflight), or a newer/better model ships and we never notice. The only thing watching for the second case was a hand-written last checked <date> comment — which went stale in 13 days, and both swaps the BE-4817 audit found (Opus 5 on 07-24, Kimi K3 on ~07-26) landed inside that window and were caught by a human, not by CI.

This adds a robot that checks every Monday. It reads the pins out of cursor-review.yml itself, asks Cursor what models actually exist, and writes what it finds into one sticky GitHub issue — which it updates in place each week and closes automatically once the drift is gone.

What this changes

New .github/workflows/cursor-review-catalog-drift.ymlschedule (17 6 * * 1) + workflow_dispatch, contents: read + issues: write:

  1. Checkout, install the Cursor CLI exactly the way preflight does, run cursor-agent models (hard fail on failure — preflight's "better red than silent" stance).
  2. Extract the pins from .github/workflows/cursor-review.yml — the Define panel models heredoc plus the judge_model input default. Nothing is duplicated, so the checker stays zero-maintenance when the pins are bumped (e.g. by chore(cursor-review): bump panel pins to claude-opus-5-thinking-max and kimi-k3-max (BE-4818) #86).
  3. Compare, using the same whole-token regex preflight uses ((?<![\w.-])<id>(?![\w.-])):
    • delisted pin — the urgent case, reported first in the issue and it fails the run (see judgment calls);
    • unpinned same-lab ids — grouped by lab, an explicit review-me list rather than a recommendation, with any inline catalog marker (e.g. Fable's (NO ZDR)) reproduced verbatim and a caution that an unmarked id is not thereby confirmed ZDR-eligible;
    • stale audit datelast checked YYYY-MM-DD older than 30 days (or missing).
  4. Open / update / close one sticky issue titled [cursor-review catalog drift] …, found by exact title prefix. Raw catalog folded into a <details> block. No findings → the sticky issue is closed and the run is a green no-op.

Comparison + rendering live in .github/cursor-review/catalog-drift.py with 30 unit tests; cursor-review.yml and the new workflow join test-cursor-review-scripts.yml's path filter. Docs: a row in the root README table, a paragraph in the cursor-review README's panel section (the last checked comment stays the human-audit record), and an AGENTS.md layout line.

Verification

  • python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py'78 passed (75 pre-existing + the new suite). actionlint (which runs shellcheck over the run: blocks) clean on every workflow; check_agents_md.py passes.
  • End-to-end dry runs of the script against the real cursor-review.yml with synthesized catalogs: a clean catalog reports no drift; a catalog with kimi-k3-code replacing kimi-k2.7-code plus a new claude-opus-5-thinking-max reproduces exactly the BE-4817 scenario — 1 delisted pin (with kimi-k3-code named as the same-lab alternative), 3 unpinned same-lab ids, stale audit date.
  • One test runs the extractors against the checked-in cursor-review.yml, so a refactor of the pins block fails a cheap unit run instead of silently making the weekly check report "no drift" forever. Verified against chore(cursor-review): bump panel pins to claude-opus-5-thinking-max and kimi-k3-max (BE-4818) #86's diff: it changes the pin values and the audit date but keeps every anchor, so extraction still works after that lands.
  • Falsification of the two hard-fail paths (they deny a capability, so they were checked empirically, not just unit-tested):
    • cursor-agent models without credentials → exit 1, empty stdout (ran it locally). So the workflow's if ! cursor-agent models guard genuinely catches the auth case rather than proceeding with garbage. A second guard rejects a non-empty but unparseable catalog rather than reporting "all four pins delisted".
    • "CURSOR_API_KEY is not available here" → falsified: this repo's own ci-cursor-review.yml runs (which need the key for preflight) are succeeding as recently as 2026-07-28, so the org secret does reach this repo and the missing-secret branch is a real-failure path, not the expected one. (Repo-level secret list shows only CLOUD_CODE_BOT_PRIVATE_KEY; the org secret list needs admin:org, which I don't have — the succeeding runs are the evidence.)

Not verified, and it cannot be until this merges: the ticket's workflow_dispatch checks (dispatch once, dispatch again to confirm the sticky issue updates in place; a fake-id branch run for the delisted path). GitHub only exposes workflow_dispatch for workflows present on the default branch, so neither can run from this branch. The dry runs above cover the same logic offline; the first scheduled/dispatched run is the live confirmation.

Judgment calls

  1. Logic lives in a tested Python script, not inline sed/yq in the workflow. The ticket sketched inline extraction; this repo's convention (AGENTS.md) is that cursor-review logic lives in .github/cursor-review/ with unit tests, and inline heredocs in a scheduled-only workflow are otherwise untestable before merge. Behavior is the ticket's; only the location changed.
  2. A delisted pin fails the run (non-urgent findings leave it green). The ticket says "no findings → green no-op" but calls delisted the urgent case to "report prominently" — a red weekly run plus the failure notification is the loudest channel available, and it self-heals the moment the pin is fixed.
  3. Lab prefixes are derived from the pins rather than hardcoded as gpt-/claude-/gemini-/kimi-. Today that resolves to exactly that list (it reuses preflight's re.split(r"[-.]", id, 1)[0] lab derivation), and it keeps working if a pin moves to a differently-branded lab. Consequence, stated deliberately: ids from labs the panel does not pin (e.g. fable-*) are not in the review-me list — they are in the raw-catalog fold, which is where an excluded-by-policy lab belongs.
  4. The sticky lookup lists open issues (limit 500) instead of --search. Search is fuzzy and eventually consistent, so a second dispatch minutes after the first could miss its own issue and file a duplicate — precisely the ticket's verification scenario.
  5. Added to test-cursor-review-scripts.yml's path filter. Small scope add: the anchor test only protects the extraction if it runs when the file it parses changes.
  6. Not a reusable workflow — it reads this repo's cursor-review.yml, which is the single source of truth for every consumer's pins, so the README row marks it internal.

Notes

…E-4819)

The per-PR preflight fails loud when a PINNED model id is delisted, but only
at review time, on somebody's PR, after the pin has already gone bad — and by
design it never notices a NEWER model shipping. The only guard against that
second case was a `last checked <date>` comment above the pins, which went
stale inside 13 days: both catalog swaps found by the BE-4817 audit landed in
that window and were caught by hand, not by CI.

Add a scheduled (Mon 06:17 UTC) + dispatchable check that reads the pins OUT
of cursor-review.yml — panel heredoc plus the judge_model input default, never
a duplicated list — diffs them against `cursor-agent models`, and reports
delisted pins, unpinned same-lab catalog ids (a review-me list, not a
recommendation; NO-ZDR markers surfaced verbatim), and a `last checked` date
older than 30 days through one sticky issue that is updated in place and
closed automatically on a clean run.

Comparison + rendering live in .github/cursor-review/catalog-drift.py with
unit tests, including one that runs the extractors against the real
cursor-review.yml so a refactor there can't silently blind the check;
cursor-review.yml joins that test job's path filter for the same reason.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c0359279-73c6-40e0-94bd-037d51dd79a0

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6c48d and 7bf109b.

📒 Files selected for processing (5)
  • .github/cursor-review/README.md
  • .github/cursor-review/catalog-drift.py
  • .github/cursor-review/tests/test_catalog_drift.py
  • .github/workflows/cursor-review-catalog-drift.yml
  • README.md
📝 Walkthrough

Walkthrough

Changes

Cursor catalog drift

Layer / File(s) Summary
Pin extraction and drift analysis
.github/cursor-review/catalog-drift.py
The checker extracts panel and judge pins, parses the live catalog, and identifies delisted, unpinned same-lab, and stale-audit findings.
Report generation and CLI execution
.github/cursor-review/catalog-drift.py, .github/cursor-review/tests/*
The checker renders issue and JSON outputs, writes GitHub Actions flags, handles failures, and includes unit and end-to-end coverage.
Scheduled catalog comparison
.github/workflows/cursor-review-catalog-drift.yml, .github/cursor-review/README.md, AGENTS.md, README.md
The workflow fetches the catalog, updates or closes a sticky issue, and fails only for urgent delisted pins; repository documentation describes the integration.
Script workflow validation
.github/workflows/test-cursor-review-scripts.yml
Path filters now run script validation when the catalog-drift workflow or related workflow files change.

Sequence Diagram(s)

sequenceDiagram
  participant Schedule as GitHub Actions schedule
  participant Catalog as cursor-agent models
  participant Checker as catalog-drift.py
  participant Issue as Sticky GitHub issue
  Schedule->>Catalog: Fetch live model catalog
  Catalog-->>Checker: Provide catalog output
  Checker->>Checker: Extract pins and analyze drift
  Checker->>Issue: Create or update findings
  Checker->>Issue: Close issue when clean
  Checker-->>Schedule: Report urgent status
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-4819-cursor-review-catalog-drift
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-4819-cursor-review-catalog-drift

Comment @coderabbitai help to get the list of available commands.

@mattmillerai mattmillerai added cursor-review Multi-model cursor review agent-coded Authored by the agent-work loop labels Jul 28, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 28, 2026 02:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/cursor-review/catalog-drift.py:
- Around line 121-148: Update the validation condition in extract_judge_model to
use the idiomatic not-in membership form instead of negating an in expression,
while preserving the existing bare model ID validation behavior.

In @.github/workflows/cursor-review-catalog-drift.yml:
- Around line 120-122: Update the sticky-issue lookup around the numbers and
existing variables to retrieve all open issues through GitHub API pagination
instead of relying on gh issue list --limit 500. Preserve local filtering for
titles beginning with PREFIX, then select the first matching issue number so the
existing duplicate-prevention flow remains unchanged.
- Around line 36-38: Separate the untrusted Cursor CLI installation and review
work from the job that performs issue updates. Pin or verify the CLI artifact
before execution, prevent the installer from influencing later GitHub CLI calls,
and grant issues: write only to the dedicated update job. Replace the gh issue
list --limit 500 lookup with complete pagination or a deterministic exact-title
query so existing sticky issues are always found.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dc21e0c7-59a2-41af-b8cf-945b6a7da83a

📥 Commits

Reviewing files that changed from the base of the PR and between f4cbb50 and 6b6c48d.

📒 Files selected for processing (7)
  • .github/cursor-review/README.md
  • .github/cursor-review/catalog-drift.py
  • .github/cursor-review/tests/test_catalog_drift.py
  • .github/workflows/cursor-review-catalog-drift.yml
  • .github/workflows/test-cursor-review-scripts.yml
  • AGENTS.md
  • README.md

Comment thread .github/cursor-review/catalog-drift.py
Comment thread .github/workflows/cursor-review-catalog-drift.yml Outdated
Comment thread .github/workflows/cursor-review-catalog-drift.yml Outdated
…icky lookup (BE-4819)

Addresses the CodeRabbit review on #87.

* Split the sticky-issue write out of the job that runs Cursor's installer.
  `curl https://cursor.com/install | bash` is a mutable third-party artifact
  Cursor ships no checksummed/versioned build of, and it ran in the same job
  that held `issues: write` — so every later `gh` call was downstream of code
  Cursor can change at will. The `drift` job now holds only `contents: read`
  and hands its rendered report to a new credential-free-of-installers
  `sticky` job via an artifact; `issues: write` exists in exactly one job that
  runs no third-party code. Same boundary groom.yml draws around its agent
  jobs, and it drops the workflow-level `permissions:` block that granted the
  write scope to every job (zizmor's excess-permissions finding).

* Paginate the sticky-issue lookup. `gh issue list --limit 500` silently
  truncates, and a sticky that falls off the end of that page gets filed a
  second time. Walk every open issue with `gh api --paginate`, filtering out
  PRs (`/issues` returns them; `gh issue list` did not).

* `value[0] not in ">|"` instead of `not value[0] in ">|"` (Ruff E713).

No behavior change to the drift report itself; the 78 script tests still pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 2
🟡 Medium 4
🟢 Low 3
⚪ Nit 1

Panel: 8/8 reviewers contributed findings.

Comment thread .github/cursor-review/catalog-drift.py Outdated
Comment thread .github/workflows/cursor-review-catalog-drift.yml
Comment thread .github/cursor-review/catalog-drift.py
Comment thread .github/workflows/cursor-review-catalog-drift.yml Outdated
Comment thread .github/workflows/cursor-review-catalog-drift.yml
Comment thread .github/cursor-review/catalog-drift.py Outdated
Comment thread .github/cursor-review/catalog-drift.py Outdated
Comment thread .github/cursor-review/catalog-drift.py Outdated
Comment thread .github/cursor-review/catalog-drift.py
Comment thread .github/cursor-review/catalog-drift.py Outdated
…e review panel (BE-4819)

Addresses the cursor-review panel findings on #87.

* HIGH — `present()` scanned the RAW catalog text with a whole-token regex, so
  a delisted pin still mentioned anywhere in the output was treated as present
  and never reported. That is not an edge case: a delist normally ships WITH a
  note naming the id it replaces ("kimi-k3-code (replaces kimi-k2.7-code)"), so
  the urgent path would have stayed silent in exactly the scenario it exists
  for, while consumer PRs went red at preflight. Now compares against the
  PARSED catalog ids, the same source `unpinned` already used. The trade-off is
  deliberate: a garbled catalog over-reports (loud) instead of under-reporting.

* HIGH — the installer step no longer receives `CURSOR_API_KEY`. The presence
  check moved to its own step, so the org secret is not in scope while an
  unpinned remote script runs. (Pinning the installer itself spans all four
  call sites in this repo — proposed as a follow-up.)

* MEDIUM — a new finding class: a pinned model whose catalog line now carries a
  NO-ZDR marker. Nothing breaks, so nothing flagged it, and private review
  diffs kept flowing to a model that may retain them. This is the one marker
  the checker interprets rather than reproduces, and only for a pin already in
  service; it reddens the run like a delisted pin.

* MEDIUM — the quoted-token fallback (and the strict-JSON path) now reject any
  entry that is not a bare model id, instead of adopting a quoted comment or a
  "TODO pick a fifth" placeholder as a pin. A guessed pin set means false
  delisted alarms every Monday, or a real pin dropped and never monitored.

* MEDIUM — the raw catalog is never echoed unfenced. `cursor-agent` output is
  semi-trusted and this repo is public, so a line beginning `::` would be
  executed by the runner (`::add-mask::`, `::stop-commands::`). The shell echo
  is wrapped in a `::stop-commands::<random>` fence; the script's
  unparseable-catalog dump prefixes every line with `| `.

* MEDIUM — the sticky key is now label + title prefix, not the public title
  alone, so the bot cannot adopt and rewrite an issue anyone opened with that
  title. (Pagination landed in the previous commit.)

* LOW — `last checked` is read only within 40 lines above the panel heredoc, so
  an unrelated match elsewhere can't shadow the real audit date; and a
  future-dated typo now counts as stale instead of reading as fresh and
  suppressing the alert until 30 days past that future date.

* LOW — merge catalog notes on a repeated id instead of keeping the first, so a
  later `(NO ZDR)` on a duplicate line is not discarded.

* NIT — catalog notes render in a length-sized, padded inline code span. They
  are unconstrained third-party text reproduced in a bot-authored issue in a
  PUBLIC repo; a bare single-backtick span let a note break out and inject
  markdown or an `@mention` that notifies real people.

Also requires a model id to contain a letter, so a numbered catalog format
parses as nothing (routing to the diagnostic "format may have changed" exit)
rather than as the ids ['1.', '2.'] with every real pin reading as delisted.

The `lab_of` prefix heuristic dropping a lab's rebranded family, and prose
words admitted as catalog ids, are left as-is and proposed as a follow-up —
both distort the review-me list, neither can produce a false green.

94 tests (was 78); actionlint + shellcheck clean.
@mattmillerai

mattmillerai commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Review resolution pass — all 13 threads (3 CodeRabbit + 10 cursor-review panel) addressed and resolved across bc8dead and 7bf109b.

Two findings genuinely changed behavior, not just hardened it:

  • present() scanned the raw catalog text with a whole-token regex, so a delisted pin still mentioned anywhere in the output read as present. That is the common case, not an edge one — a delist normally ships with a note naming the id it replaces (kimi-k3-code (replaces kimi-k2.7-code)), so the urgent path would have stayed silent in precisely the scenario it exists for, while consumer PRs went red at preflight. Now exact membership against the parsed ids, the same source unpinned already used. This makes the drift check stricter than the per-PR preflight, which still scans text: a garbled catalog now over-reports rather than under-reports, guarded by the existing "no ids could be parsed" hard exit.
  • New finding class from the panel: a pinned model whose catalog line now carries a NO-ZDR marker. Nothing breaks, so nothing flagged it, and private review diffs keep flowing to a model that may retain them. It reddens the run like a delisted pin. This is the one marker the checker interprets rather than reproduces, and only for a pin already in service — markers on promotion candidates are still passed through untouched.

The workflow's security posture changed shape. issues: write no longer coexists with curl https://cursor.com/install | bash: the drift job holds only contents: read and hands its rendered report to a separate sticky job via an artifact — the boundary groom.yml draws around its agent jobs. The installer step also no longer receives CURSOR_API_KEY. Raw cursor-agent output is never echoed unfenced (a line beginning :: would be executed by the runner, in a public repo's log).

Also fixed: the pin extractor now rejects any entry that is not a bare model id on both the strict-JSON and quoted-token paths (a valid JSON array can carry a "TODO pick a fifth" placeholder); the sticky-issue key is label + title prefix over full pagination, so it is neither truncatable nor squattable; last checked is read only near the pins and a future-dated typo counts as stale instead of reading as fresh; repeated catalog ids merge their notes so a later (NO ZDR) is not discarded; and catalog notes render in a correctly-sized inline code span, since an injected @mention in a bot-authored public issue would have pinged real people.

Two follow-ups proposed rather than fixed here:

  1. Pin/verify the Cursor CLI installer across all four curl|bash call sites in this repo (cursor-review.yml:348/484/609 plus this one). Held for a human call on the bump path — the installer is internally versioned (downloads.cursor.com/lab/2026.07.23-e383d2b/..., verified 2026-07-27) but ships no checksum, and a pinned CLI is itself a new staleness source.
  2. lab_of's prefix heuristic drops a lab's rebranded family (OpenAI's o<n> series is the precedent) from the review-me list. Neither this nor the prose-word false positive can produce a false green, and fixing it means choosing a design (quiet catch-all section vs. lab-alias table), not tightening a regex.

Tests: 94, up from 78 — every fix above has a named regression test. actionlint and shellcheck clean; smoke-run end-to-end against the real cursor-review.yml for the healthy, delisted-only-in-a-successor-note, and reclassified-NO-ZDR cases.

One thing to know before merging: the 4-lab × 2-type panel reviewed 6b6c48d. 7bf109b is substantial and has not been through it, because cursor-review is label-triggered and a push does not re-fire it. If you want a fresh panel on the final diff, remove and re-add the review label.

@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4851 — Pin and verify the cursor-agent CLI installer across all four curl|bash call sites
  • BE-4852 — Make the catalog-drift lab/id heuristics survive a rebranded model family

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants