What this skill does
Pulls open PRs from GitHub via the gh CLI and formats them into a Slack-ready triage message grouped into mutually-exclusive priority buckets. Unlike pr-review-report, this variant has no helper script — you drive gh yourself, apply the bucketing rules below, and render the output. Follow the rules strictly so the report stays reproducible.
Inputs
Collect these from the user (or infer from the conversation):
org(required) — GitHub organization.teams(optional) — comma-separated team slugs (noorg/prefix). A PR matches if any of these teams is a requested reviewer.authors(optional) — comma-separated GitHub usernames. A PR matches if its author is any of these users.repos(optional) — comma-separated repo names (no org prefix). Restricts the search to these repos.stale_hours(optional, default24) — hours since last activity before a PR is flagged as stale.max_age_days(optional, default7) — days since the PR was opened before it's flagged as too old. Independent of activity: catches long-running PRs even if someone commented recently.extra_bots(optional) — comma-separated GitHub logins to treat as bots, matched case-insensitively. Use for machine accounts GitHub doesn't flag itself (e.g. an internal automation user that opens dependency PRs).
At least one of teams, authors, or repos must be provided. If all three are empty, stop and ask the user — don't run a repo-wide open-PR query against an entire org.
Filter semantics: within a list, values are OR'd (any team OR any author). Across lists they AND (must be reviewed by one of those teams AND authored by one of those authors AND in one of those repos). Repos further restrict the search.
If any required input is missing or ambiguous, ask the user before running gh. Don't guess org/team slugs.
Step 1 — Verify gh is available
Run gh --version once. If it's missing or the major version is not 2, stop and surface the error — the flags and JSON fields below are pinned to gh v2. Don't try to proceed on an unknown major.
Step 2 — Search for candidate PRs
gh search prs takes a single --review-requested and a single --author. When the user supplies multiple teams or authors, run one query per (team × author) combination and dedupe results by URL.
Base command:
gh search prs --state open --limit 1000 --json url,repository,isDraft,author \
[--review-requested <org>/<team>] \
[--author <user>] \
[--repo <org>/<repo> ... | --owner <org>]
Rules:
- If
teamsis set, iterate over each team and pass--review-requested <org>/<team>. Otherwise omit that flag. - If
authorsis set, iterate over each author and pass--author <user>. Otherwise omit. - If
reposis set, pass one--repo <org>/<name>per repo. Otherwise pass--owner <org>once. - Merge all query results into a map keyed by
urlto dedupe.
Step 3 — Skip drafts and bots early
From the merged search results, drop any PR where:
isDraftistrue→ count towardskipped.drafts.- Author is a bot → count toward
skipped.bots.
A PR counts as bot-authored if any of these holds on its author object:
author.is_botistrue, orauthor.type == "Bot".author.loginends with[bot](case-insensitive).author.login(lowercased) is in{dependabot, renovate, renovate-bot, github-actions}.author.login(lowercased) is in the user-suppliedextra_botsset.
Step 4 — Fetch details per surviving PR
For each remaining PR, run:
gh pr view <url> --json number,title,url,author,isDraft,createdAt,updatedAt,additions,deletions,files,labels,reviewDecision,mergeable,reviews,statusCheckRollup
The search index can lag, so re-check isDraft and the bot rules on the detail payload too; if either fires now, move the PR to the corresponding skipped counter and drop it.
Parallelize these detail fetches where reasonable — each PR is independent.
Step 5 — Compute per-PR signals
For each detailed PR, derive:
age_hours— hours betweencreatedAtand now (UTC).idle_hours— hours betweenupdatedAtand now (UTC).ci— one ofsuccess | failure | pending | none, derived fromstatusCheckRollup:noneif the rollup is empty/missing.failureif any check'sstate/conclusion/statusis in{FAILURE, ERROR, TIMED_OUT, CANCELLED, ACTION_REQUIRED}.- else
pendingif any check is in{PENDING, QUEUED, IN_PROGRESS, WAITING}or has an empty state. - else
success.
mergeable— pass throughMERGEABLE | CONFLICTING | UNKNOWN.decision— pass throughreviewDecision:APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | null.approvers— unique, sorted list ofreviews[].author.loginwherereviews[].state == "APPROVED".
Step 6 — Bucket each PR (first match wins, in this precedence)
needs_attention— any of the following fire:ci == "failure"→ reason"CI failing".mergeable == "CONFLICTING"→ reason"merge conflict".idle_hours >= stale_hours→ reason"stale <fmt_age(idle_hours)>".age_hours >= max_age_days * 24→ triggers the bucket, but only add an explicit"old <fmt_age(age_hours)>"reason if no other reason fired (otherwise the leadingopen <age>in the rendered line already conveys it andoldwould be redundant).
ready_to_merge—decision == "APPROVED"ANDci in {success, none}ANDmergeable != "CONFLICTING". No reasons.in_discussion—decision == "CHANGES_REQUESTED". Reason:"changes requested".awaiting_review— everything else. No reasons.
Where fmt_age(hours) is <N>h if hours < 24, otherwise <N>d, rounding to the nearest integer.
"Stale" and "old" are intentionally separate signals: a PR can be young but abandoned (stale, not old), or long-running but with recent activity (old, not stale). Either is worth a reviewer's attention.
Step 7 — Order within each bucket
Sort each bucket by age_hours descending (oldest PR first). Those should surface at the top of the bucket.
Step 8 — Render the Slack report
Format the result into Slack mrkdwn following the template below. Print the final report inside a fenced code block so the user can copy-paste it directly into Slack.
Template
Every bullet uses the same shape: open <age> first (always labeled — so the reader never has to guess what a bare number means), then bucket-specific fields.
*PR review report — <team or author or "repos">* · <total> open · _<generated date, human-readable>_
:fire: *Needs attention* (<count>)
• <<url>|<repo>#<number>> — <title> _by @<author>_ · open <age> · <reasons joined with ", ">
...
:white_check_mark: *Ready to merge* (<count>)
• <<url>|<repo>#<number>> — <title> _by @<author>_ · open <age> · approved by @<a>, @<b>
...
:speech_balloon: *In discussion* (<count>)
• <<url>|<repo>#<number>> — <title> _by @<author>_ · open <age> · <reasons>
...
:eyes: *Awaiting review* (<count>)
• <<url>|<repo>#<number>> — <title> _by @<author>_ · open <age> · +<additions>/-<deletions>
...
Rendering rules
- Omit empty buckets entirely — don't print a heading for an empty bucket.
- Header line — describe the scope from the filters. Single team →
team <name>; multiple teams →teams <t1>, <t2>. Single author →@<user>; multiple authors →@<a>, @<b>. Combine non-empty pieces with/(e.g.teams choco-ai, platform / @ebukaume). If only repos are set, fall back torepos: <list>. - Age — prefix with
openand formatage_hoursas<N>hunder 24 hours, otherwise<N>d. Round sensibly. - Links — use Slack's
<url|text>syntax. The link text should be<repo-short-name>#<number>(strip theorg/prefix from the full repo name for brevity). - Sizes — only include
+additions/-deletionsin the Awaiting review bucket. Elsewhere it's noise. - Reasons — join the computed reasons with
,and drop them in verbatim. The leadingopen <age>already covers open-age, so only emitold <Nd>when age is the sole trigger. - Approvers (Ready to merge) —
approved by @a, @b; if the approvers list is empty, write justapproved. - Footer — if
skipped.draftsorskipped.botsare non-zero, add a final italic line:_Skipped: <N> drafts, <M> bot PRs_. Omit when both are zero. - No truncation — print every PR, even if the message is long. The user asked for one large message.
- Ordering — preserve the bucket ordering from Step 7 (oldest PR first).
Example output
*PR review report — team platform* · 5 open · _Apr 16, 2026_
:fire: *Needs attention* (3)
• <https://github.com/acme/api/pull/300|api#300> — Refactor token cache _by @jess_ · open 30d · stale 15d
• <https://github.com/acme/api/pull/312|api#312> — Harden auth middleware _by @jess_ · open 4d · CI failing, stale 4d
• <https://github.com/acme/web/pull/188|web#188> — Long-running React 19 upgrade _by @tom_ · open 12d · old 12d
:white_check_mark: *Ready to merge* (1)
• <https://github.com/acme/api/pull/320|api#320> — Add rate-limit headers _by @sam_ · open 2d · approved by @jess, @tom
:eyes: *Awaiting review* (2)
• <https://github.com/acme/web/pull/205|web#205> — Fix hydration warning on /dashboard _by @lee_ · open 1d · +42/-18
• <https://github.com/acme/api/pull/325|api#325> — Add /health endpoint _by @sam_ · open 6h · +80/-3
_Skipped: 3 drafts, 4 bot PRs_
Notice how the three needs-attention PRs each tell a different story at a glance:
#300: old and gone quiet (open 30d · stale 15d)#312: young, but actively broken (open 4d · CI failing, stale 4d)#188: long-running but still being worked on (open 12d · old 12d) — age was the sole trigger, soold 12dis added explicitly so the reader sees why it was flagged.
Edge cases
- No matching PRs — every bucket is empty. Print a short message:
No open PRs match those filters.Don't print empty bucket headings. ghfails — usually an auth issue or an invalid team slug. Surface the error to the user verbatim; don't retry blindly.- Very large results (hundreds of PRs) — still render the full report as specified. If the user asks to summarize, that's a follow-up; the default output is complete.
Why pure-prompt
This variant keeps the bucketing rules in the SKILL.md itself so the skill ships as a single reviewable file with no runtime dependency on Python. The trade-off vs pr-review-report: bucketing is done by the agent, so minor wording differences between runs are possible. If you need strict reproducibility (CI, scheduled digests), prefer pr-review-report; if you want a dependency-free skill that just works off gh, use this one.