Review-Queue Skill for Marigold Design System

SkillDev tools

Lets your agent build one ranked digest of which GitHub pull requests are waiting on your review or need rework.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Review-Queue Skill for Marigold Design System skill

About this capability

Marigold repo, Produce one ranked digest of the review work waiting on you, across every repo in the marigold-ui org. Two sections: the open PRs that are actually yours to review, meaning nobody else has reviewed them yet or you already have, ranked by the linked DST ticket's sprint and board rank

What this skill tells your AI

The instructions your AI receives, as published by marigold-ui/marigold in .claude/skills/review-queue/SKILL.md and read by ahel’s review.

Answer two questions that currently need several browser tabs: what should I review next, and which of my PRs bounced back. GitHub can list both, but it cannot know that a PR attached to a sprint-committed ticket outranks a drive-by change. That signal is on the Jira board, in another system.

So this skill is the join. It gathers PRs from GitHub, ranks them by the board, and prints one capped digest.

This skill is read-only, and that is why it has no confirmation gate. /vrt, /pick-up and /triage-feedback each open with a gate because each of them eventually posts, dispatches or transitions something. This one has no outward call to gate: every command in allowed-tools is a read, and there is no Write, no Edit, no git, no gh pr review. The narrow list is the guarantee, not the prose. Acting on what the digest surfaces is /review-pr's job and /triage-feedback's.

Board mechanics, JQL traps and field ids live in ../references/jira-board.md. Read it before writing a query.

Usage

/review-queue              # both sections
/review-queue --review     # awaiting your review only
/review-queue --rework     # your PRs needing rework only
/review-queue --all        # lift the per-section cap

Passing both --review and --rework is the same as passing neither. --all is orthogonal and combines with either, so --review --all is every row of section 1, uncapped.

The board tail from step 8 is review work, so it rides with section 1. It renders under --review and under no flags, and --rework drops it along with section 1.

Workflow

1. Establish who you are and what was asked for

gh api user -q .login

The login splits the two sections, recognises your own approvals, and tells you whose turn a thread is on. Never read it from a value written down here: this file is committed and every developer runs it as themselves.

Name the sections that ran, in the output. A section that was switched off contributes no rows, and an empty section has to be distinguishable from one that was never gathered. This is the same failure /triage-feedback guards against with its source list.

2. Discover which repos have anything open

One call, org-wide:

gh search prs --owner=marigold-ui --state=open --limit 100 --json repository

One field, because one field is used. Everything else this call could return is fetched again per repo in step 3, which is where the branch name has to come from anyway.

The org is the allowlist. There is no configurable repo list, deliberately: --owner=marigold-ui already covers every work repo, excludes personal forks and OSS clones by construction, and cannot go stale the way a hand-maintained list in this file would. DST-1531 asked for the list, and the flag is the same thing without the maintenance.

There is no --review-requested=@me, and that is the largest departure from the ticket. DST-1531 specifies exactly that flag as the gather, which would be the obvious way to answer "what is waiting on me" and returns nothing at all here: of 15 open PRs on marigold, two carry a review request of any kind (#5740 to aromko, #5748 to OsamaAbdellateef) and none request the author of this file. The team assigns reviewers in conversation rather than in GitHub's field, so a queue built on it would be permanently empty and look like a clear board. So the sweep is org-wide and "is this mine to review" is decided further down, from who has actually reviewed it. That test is in step 4, and it is doing the job the flag was meant to do.

The result is genuinely multi-repo. At the time of writing it spans marigold (15), search-form-pattern (11), reference-app (3), insight (2) and insights (1). Note that insight and insights are two different repositories.

A repo is absent because it has no open PRs, and that is the whole reason. starter and ai-assistant are live repos in the org and never appear, because there is nothing open in them. Verified against a per-repo gh pr list over all 16 org repos: the direct counts match this search exactly, so the search index is not quietly dropping anything.

Drop archived repos. Their PRs cannot be merged, so reviewing one is effort that can never land:

gh repo list marigold-ui --limit 100 --json nameWithOwner,isArchived

nameWithOwner rather than name, so it joins directly against what the search returned. Asking for name and comparing it to owner/repo matches nothing, which fails in the dangerous direction: the filter drops nothing and every archived repo stays in the queue looking reviewable.

Two of the five repos above are archived, insight and search-form-pattern, and between them they hold 13 of the 32 open PRs. Today every one of those is a draft or a renovate PR, so the step 3 filters happen to remove them all, which is luck rather than design: one non-draft human PR on an archived repo would sit in the queue looking reviewable. Count them under "repos searched" and say how many were skipped.

This also retires the example DST-1531 was written around. The ticket cited marigold-ui/insight PRs as the live proof that key-less PRs must not be dropped. Those two PRs still exist, but they are drafts on an archived repo now, so reference-app is the honest key-less example.

Read the cap back. Exactly 100 results means the gather is partial, and the digest has to say so. A digest that silently drops the 101st PR looks complete and is not.

Take the distinct repository.nameWithOwner values. Querying only the repos that have open PRs is why this step exists at all rather than looping over a fixed list.

Loop with while read, never for r in $REPOS. zsh does not word-split unquoted parameters, so the for form passes all five repo names to --repo as one argument. gh then fails, and if its stderr is suppressed the digest comes back empty and looks like a quiet board. This was hit while building the skill, and it is the same class as /triage-feedback's rule about quoting a URL containing ?. set -- $spec inside such a loop fails the same way, so read the fields with while IFS=' ' read -r a b rather than splitting a line yourself.

Write .name? // "" with spaces, never .name?//"". jq reads ?// as the destructuring-alternative operator and refuses to compile. The guard itself is needed because a StatusContext has no name at all, only context, so step 7's classification has to reach for it defensively. The failure is loud on its own and quiet inside a loop, where the surrounding rows still print and only the cells fed by that filter come out wrong. Same shape as the trap above, a shell or jq metacharacter doing something other than what it looks like, so they live together.

While checking that: workflowName is always present on a CheckRun, sometimes as an empty string. Absent and empty are different tests, and it is the empty one step 7 talks about. The full key set is __typename, completedAt, conclusion, detailsUrl, name, startedAt, status, workflowName for a CheckRun against __typename, context, startedAt, state, targetUrl for a StatusContext, which is also why a failing deploy is read off state rather than conclusion.

3. Pull the detail the search cannot give you

gh search prs supports 17 --json fields and headRefName is not one of them, so the branch name (the best source of a ticket key) has to come from gh pr list, per repo:

gh pr list --repo <owner/repo> --state open --limit 100 \
  --json number,title,author,isDraft,headRefName,baseRefName,reviewDecision,url,updatedAt,labels

Keep this list, unfiltered, for step 8. The filters below are for the two rendered sections only, and step 8 needs to know about every PR that exists, drafts and bots included.

Then filter:

  • Drop bots (author.is_bot). Renovate's chore(deps) PRs and the release: version packages PR carry no ticket and need no human queue position.
  • Drop drafts. A draft is not asking for review, and your own draft is work in progress rather than rework.
  • Split on author.login. Yours feeds section 2, everyone else's feeds section 1.

reviewDecision is a string enum: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED. REVIEW_REQUIRED also means nobody has looked yet, so it is the normal state of a healthy new PR and never on its own a rework signal.

Mark a PR whose baseRefName is not main. It is stacked on another branch, so reviewing it before its base lands may be premature and its diff may include the base's commits. Say so on the row rather than dropping it: a stacked PR is often exactly what wants reviewing, but the reader needs to know the order. Every open PR in the org has main as its base today, so this rule currently never fires and is here for the first stack rather than for a live example.

4. Count unresolved threads, in one batched call

REST does not expose whether a review thread is resolved, so this has to be GraphQL. Query the whole shortlist in one document with aliased fields rather than one request per PR:

gh api graphql -f query='
query($o:String!,$r:String!){
  repository(owner:$o,name:$r){
    pr5779: pullRequest(number:5779){ ...prBits }
    pr5761: pullRequest(number:5761){ ...prBits }
  }
}
fragment prBits on PullRequest {
  number headRefName headRefOid reviewDecision
  author{ login }
  reviewThreads(first:100){
    totalCount pageInfo{hasNextPage}
    nodes{ isResolved isOutdated }
  }
  latestReviews(first:20){ nodes{ author{login} state commit{oid} } }
}' -f o=marigold-ui -f r=marigold

One document per repo, since repository is the root. Build the aliases from the shortlist.

Unresolved count is the highest-signal column in the whole digest, and it is orthogonal to reviewDecision. Measured on real PRs: 5776 and 5761 are both CHANGES_REQUESTED, but 5776 has 2 unresolved threads out of 9 and 5761 has 3 out of 23. Ranking on reviewDecision alone would treat those as equal, and ranking on total threads would put the almost-finished one first.

  • reviews is deliberately not in that query, because a review count is not a measure of feedback. Every inline comment submission lands as its own COMMENTED review, so PR 5761 carries a reviews.totalCount of 47 against 3 live threads. latestReviews collapses that to one node per author, which is the only shape anything here needs. Ask for reviews with a last: window and you get the window back rather than the total, which is its own way to misreport this.
  • isOutdated means the diff moved under the thread. Carry it through as a staleness hint rather than re-deriving it from the diff. Report it, do not subtract it: a thread on a moved hunk is often still a live objection.
  • Read the caps back, both hasNextPage and a totalCount above the 100 fetched.
  • Drop from section 1 any PR whose latestReviews shows your login with state APPROVED. You are done with it, whoever else is not.

latestReviews[].state is not the same enum as reviewDecision. It carries APPROVED, CHANGES_REQUESTED, COMMENTED and also DISMISSED, which is a review someone has since dismissed. Treat a dismissed review as absent rather than as an opinion. reference-app#306 is the live example.

Is it yours to review at all

Cut a PR from section 1 when someone else has reviewed it and you have not. They are already on it, and a second opinion nobody asked for is the cheapest thing to drop from a queue. #5684 is the case: sebald and jim761 have both commented, every thread is resolved, and there is nothing there for a third reviewer.

So section 1 keeps exactly two kinds of PR:

  • Nobody has reviewed it. No reviews at all, or only DISMISSED ones, which count as absent.
  • You have reviewed it. Your involvement continues, whatever anyone else has said.

An unresolved preview comment does not count as someone reviewing. It is feedback left for the author, not a code review, so #5740 and #5748 stay in the queue: nobody has looked at either, and their only open feedback is a preview comment. Reading the Open feedback column as "somebody is on it" would hide two PRs that no human has read.

Name the cut rows, never drop them silently. They go in a +N cut (others reviewing) line under the table, one clause each. A PR that others commented on and nobody approved can otherwise sit forever without ever reaching you, which is the failure mode of every review dashboard that filters by cleverness.

Whose turn is it

A PR you have already reviewed is not automatically off your queue, and it is not automatically on it either. This is the single biggest correctness question in section 1, because the wrong answer puts work you have finished at the top of your own list.

Read it from two fields:

Your latest review on itThe author has pushed sinceTurn
CHANGES_REQUESTEDnotheirs, demote per step 7
CHANGES_REQUESTEDyesyours, they answered your review, rank normally
COMMENTED onlyeitheryours, a comment is not a verdict
APPROVEDeitherdropped above
noneyours, first review

"Pushed since" is your latest CHANGES_REQUESTED review's commit.oid against the PR's current headRefOid. Both come off latestReviews, which is one node per author and so is already the "latest review" this table asks about. Verified live: on #5779 both oids are 5f38f3e, so it is still the author's turn, while on #5776 the review sat on 1165e01 and the head has moved to 63ea2b5, so the author has answered and it is yours again.

Do not use reviewDecision for this. It stays CHANGES_REQUESTED until a reviewer approves, so it cannot distinguish "they have not replied yet" from "they pushed fixes and are waiting on you", which are the two cases that matter most.

5. Extract the ticket key

Read both the branch and the title, every time. This is not a first-hit-wins cascade. /create-pr stops at the first hit because it only needs one answer, and copying that here silently picks the wrong ticket when the two disagree.

  1. Extract from headRefName and from the PR title independently.
  2. If they agree, or only one yields a key, use it.
  3. If they disagree, use the title's key and say so on the row.
  4. Neither yields a key, and the PR is unranked.

The title wins because it is written as prose, read by every reviewer, edited when wrong, and lands in the changelog. A branch name is typed once at creation and never corrected.

#5776 is the worked example, and it is why this rule exists. Its branch is dst-1745_fix-popover and its title is fix(DST-1754): keep popovers inside the body's clip box at the window edge: transposed digits. DST-1745 is a Core-only invoice-printing migration that is already Done, DST-1754 is the popover bug the PR actually fixes and is In Review in the active sprint. Taking the branch gave the wrong ticket, demoted the PR as "ticket is Done", and made step 8 report DST-1754 as having no PR. One typo, three wrong rows. Checked across all 32 open PRs in the org, it is the only disagreement, so surfacing it costs one flag and almost never fires.

Match \[?(DSTSUP|DST)-([0-9]+)\]? case-insensitively, anchored on the whole key. A looser dst-?(\d+) mis-parses DSTSUP-275, and there are real branches and commits for both projects.

It has to be tolerant, because three branch conventions coexist and none is going away:

ShapeReal example
<type>/DST-NNNN-kebabfeat/DST-1527-ai-review-ci
dst-nnnn_slugdst-1607_boolean-fields, dstsup-275_fix-filefield
DST-NNNN-kebabDST-889-link-new-tab-indicator

Commit and title scopes are sometimes bracketed (fix([DST-1684]):), and one branch on this repo has an emoji in its name. Tolerate all of it, and never assume a type/ prefix is present.

6. Join the board

One JQL call per project key, listing only the keys the shortlist actually produced:

key IN (DST-1526, DST-1607, DST-1745) ORDER BY Rank ASC

fields: ["summary", "status", "assignee", "customfield_10020", "parent"].

Read status.statusCategory.key, never status.name. The name comes back localised, so a de account reads Fertig and Wird überprüft where an en one reads Done and In Review. The category key is neither translated nor renamed, and it is the same three values (new, indeterminate, done) in both projects despite DST and DSTSUP having entirely different status ids. Step 7 and the Done edge case both depend on this.

The response order is the ranking. Nothing needs to read a rank value, and none is returned. One query per key because rank is a global lexorank string, so a mixed DST and DSTSUP set comes back interleaved in an order that means nothing. See ../references/jira-board.md for both.

There is no priority input, and its absence is deliberate. DST-1531 specified ranking by "open-sprint membership + priority + board rank". Priority is a dead field on this board: three open issues carry one at all, none above Medium, and current issues do not return the field. Ranking by it would sort by a constant while appearing to sort by importance, which is worse than not claiming to rank at all.

7. Rank, and cap

Section 1, awaiting your review. Five tiers, top to bottom. They are named rather than numbered because the prose below refers to them and numbers drift when a tier is inserted:

  1. Active sprint. Match customfield_10020[].state == "active", never [0]. DST-1625 is the live worked example: it carries Calvin Klein (closed) at [0] and Enchantment (active) at [1], so indexing gets the answer exactly backwards. DSTSUP issues have no sprint field at all, so treat missing as "no sprint" rather than as an error.
  2. Ticket, no sprint.
  3. Unranked, meaning no ticket key. Never dropped. A PR with no ticket is still a PR someone is waiting on, and repos outside DST's reach have no keys at all.
  4. Author's turn: step 4 put the ball in their court, because you requested changes and they have not pushed since. Nobody is blocked on you.
  5. Ticket already Done, meaning status.statusCategory.key == "done". Its sprint and rank are live values on a closed ticket, so the active-sprint tier would otherwise float it to the top of the queue. See the edge case below for why it stays in the digest at all.

Board rank orders the rows inside each tier, taken from step 6's response order. It is not a tier of its own: a well-ranked ticket that is not in the sprint still sits below every sprint row. Do not print a rank number. See step 9.

The author's-turn tier is the one that matters most in practice, because it fires often. Without it a PR you have already reviewed sits near the top of your own queue on the strength of its sprint and rank, and you re-read work you finished days ago. Measured on the first live run: #5779 ranked third while the ball was in the author's court, which is the worst single row the digest produced. It is a demotion rather than a drop so that the queue still shows what you are waiting on, and a demoted row says so in Ranked by.

Test the Done tier on the category key, not on the status name. status.name == "Done" matches nothing on a de account, where the value is Fertig. The tier then silently never fires, and the closed ticket's live rank floats its PR to the top of the queue, which is the exact failure this tier exists to prevent. A ranking rule that fails silently is worse than one that is absent, because the digest still looks ranked.

Section 2, your PRs needing rework. The filter is CHANGES_REQUESTED, or unresolved threads, or unresolved preview comments, or a CI failure. Sort by:

  1. A CI failure or CHANGES_REQUESTED first.
  2. Unresolved thread count, descending.
  3. Unresolved preview comments.
  4. Board rank.

Preview comments qualify a PR for this section but sort below a broken build and below review threads, because a build failure blocks everything and a preview comment is usually a smaller edit.

Fetch check status for every shortlisted PR, before either filter runs. It is one call per PR:

gh pr view <n> --repo <owner/repo> --json statusCheckRollup

Not for the rows about to be printed, which is the ordering it is tempting to write and cannot work: two of section 2's four qualifying conditions, a CI failure and unresolved preview comments, exist nowhere but this rollup, so the section cannot know its own membership until the calls are made. Section 1 needs it on every candidate too. #5740 and #5748 both belong in the queue only because of a red preview check, and reporting "nothing bounced back" is a claim about all of your open PRs rather than about a subset. Run it once over the shortlist step 3 produced and rank from what comes back.

Do not print the rollup. There are about 27 entries per PR on marigold (Builds, CodeQL, Format, Lint, Size Limit, Typecheck, four Unit Tests shards, four Storybook shards, three Vercel deploys, and the repo's own guards) and the digest dies of it.

A red check is not one thing, and any(conclusion == "FAILURE") is the wrong aggregate. Classify into three, because they want three different responses. Match the rows in order and stop at the first hit, because the name test has to run before the type test:

What it isHow to recognise itWhat it means
Unresolved preview commentsCheckRun named Vercel Preview CommentsSomeone left toolbar feedback on the preview. Not a build failure at all
A deploy or integration problem__typename == "StatusContext"Report it as itself. Neither of the above
Our CI failedany remaining CheckRun, workflowName or notA real build failure. The PR is not ready to review or to merge

Verified on #5776: 22 of its checks are CheckRuns carrying a workflowName (Builds, CodeQL, Format, Test, Typecheck, the guards), three are StatusContext Vercel deploys, and two are CheckRuns with an empty workflowName.

Those two empty-name checks are why the CI row is last and tests no workflowName at all. They are Vercel Preview Comments, which row 1 has already claimed by name, and a bare CodeQL aggregate sitting alongside the Analyze (javascript) and Analyze (typescript) runs that carry workflowName: "CodeQL". An earlier version of this table read the empty name as third-party and sent that CodeQL to the integration row, which files a real security-scan failure as somebody else's problem. There is no live example of a CheckRun that is genuinely third-party, so StatusContext carries that row alone.

Vercel Preview Comments is worth more than the mislabelling it caused. It fails while preview comments are unresolved, which makes it the only window gh has into Vercel toolbar feedback: this skill does not talk to the Vercel MCP, and /triage-feedback does. It is also independent of GitHub review threads. #5740 has zero unresolved GitHub threads and a red preview check, so folding it into a thread count would lose it entirely. Carry it as feedback and point at /triage-feedback, never as failing CI.

Cap each section at 7. --all lifts it. The cap is the feature: a digest nobody finishes reading by week two has failed, whatever it contains.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
144
Forks
14
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
review-queue-marigold-ui
Source
github.com/marigold-ui/marigold