Triage unboarded Inspector issues — the two-pass sweep, the priority rubric and its score comment, and the board audit that checks the whole board against its i
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-issue-triage-6c69d078c7d4 ,按照其中的说明把「issue-triage」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
An issue needs triage when it has no board card — regardless of who filed
it. That is the whole test, and it is deliberately about state, not
authorship. The milestone is not part of the test; it decides where the card
lands (milestoned → Todo, unmilestoned → Incoming), so an
approved-but-unboarded issue is still swept in rather than skipped (Copilot). An outside reporter has no board access, so their
issue necessarily lands this way — but so does a maintainer's, whenever they
open one by hand instead of through the /issue-create flow. Write access makes
the board reachable, not automatic. What puts an issue in Todo is somebody
performing the approval, and an issue nobody did that for has not been approved,
whoever's name is on it.
"Triage new issues" means running pass 1 over every unboarded open issue, then the board audit. Pass 2 is a human judgment call and is never done unprompted.
For each open issue with no card:
v2 unless it's a fix for released v1 behavior)
and the type label (bug / enhancement / documentation / chore /
question — see /issue-create for the table). An outside reporter cannot
set either, so both are applied here.v2 → #28, v1 → #11.Incoming.The one exception is an unboarded issue that already carries a milestone. Someone recorded the approval and only the card is missing, so board it straight into Todo, keeping the milestone. Otherwise triage would silently un-approve work a maintainer had already scheduled.
Diff the open issues against both boards. Diffing against #28 alone is
wrong: a v1 issue correctly carded on #11 is reported as unboarded and gets
double-boarded (a real defect a past sweep introduced — #1929 reproduced it).
D=$(mktemp -d)
gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \
--json number,milestone > "$D/open.json"
# item-list truncates SILENTLY past --limit (and a failed call writes nothing), and a
# missing card reads as an "unboarded" issue that then gets double-carded — so an
# incomplete dump is deleted, and the steps below fail on the missing file.
for P in 28 11; do
gh project item-list $P --owner modelcontextprotocol --format json --limit 2000 > "$D/b$P.json"
jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \
|| { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; }
done
# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues.
jq -s '[.[].items[] | select(.content.type=="Issue"
and .content.repository=="modelcontextprotocol/inspector")
| .content.number]' "$D/b28.json" "$D/b11.json" > "$D/boarded.json" \
|| rm -f "$D/boarded.json"
# Prints the destination too: milestoned already → Todo, otherwise → Incoming.
jq -r --slurpfile b "$D/boarded.json" \
'.[] | select(.number as $n | ($b[0]|index($n))|not)
| "#\(.number)\t→ \(if .milestone then "Todo (has milestone \(.milestone.title))" else "Incoming" end)"' \
"$D/open.json"
A maintainer reads the Incoming column and, for each issue worth doing, assigns a milestone and moves the card to Todo (or In Progress if picking it up now). That is the whole approval gesture.
This pass is a judgment call reserved for a human — deciding what ships in which release is not something to infer from a rubric. Never promote a card out of Incoming as part of a routine sweep; "triage new issues" stops at the end of pass 1.
Issues that shouldn't ship stay in Incoming (or get closed). Incoming is the review queue, and "milestoned" is the line between reviewed and not — which is why the milestone stays off in pass 1, and why the rubric can treat a milestone as a real signal rather than a formality every issue carries.
Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later.
Axis 1 — Severity / impact (1–5). How bad is it when it happens?
| Score | Means |
|---|---|
| 1 | Cosmetic — a typo, a misaligned control, a wording nit. |
| 2 | Minor friction with an easy workaround. |
| 3 | A real feature is broken or missing; the workaround is annoying or partial. |
| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. |
| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. |
Axis 2 — Urgency / staleness (1–5). How time-sensitive or neglected is it?
| Score | Means |
|---|---|
| 1 | No time pressure; nothing waits on it. |
| 2 | Wanted eventually. |
| 3 | Wanted this milestone, or has sat >90 days with no activity. |
| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). |
| 5 | Blocking a release, or actively hurting users on a published version right now. |
Signal indicators (+1 each — not an axis of their own). Corroborating evidence that the two axes may have undercounted:
bug or security-related labelFields → Priority to Urgent or High — +1, flat,
whichever of the two they picked. It does not map to a band, and Urgent
earns exactly what High earns.Bands. Axes give 2–10 and there are six bonuses, so the total runs 2–16.
| Total | Priority | Meaning |
|---|---|---|
| 12+ | Urgent | Drop what you're doing. |
| 9–11 | High | Next up after current work. |
| 6–8 | Medium | Scheduled normally. |
| ≤5 | Low | Nice to have; may sit. |
Severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands High. That's deliberate — Urgent is reserved for a severe problem that something else also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around.
The boards are private (public: false, both #28 and #11 — verified
2026-08-01). The Status and Priority a maintainer assigns are visible only to
people with project access: a reporter cannot see them, cannot set them, and
will never learn how their issue was scored. Board priority is a maintainers'
working queue, not a published commitment.
The org-level Fields → Priority is the opposite. It renders on the public
issue page and is not part of maintainer triage, so any value there is
untrusted — we didn't put it there, and it carries a preference rather than
an assessment. That asymmetry is why it earns a flat +1 and nothing more:
Urgent
and High, and can lift an issue at most one band. Nothing a reporter can type
reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11
on maintainer-assessed axes — at which point the reporter is not the reason.Urgent does not make the
card Urgent. Doing that would hand queue position to anyone with a GitHub
account, and the queue would sort by assertiveness instead of impact.Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on who assessed the issue rather than on who was technically able to click.
The board stores only the result — a card reading High with no trace of how
it got there — and since the boards are private, that result is also invisible
to the reporter. So post the arithmetic as a comment on the issue. Without
it, a later re-scoring has no way to tell a considered judgment from a guess.
gh issue comment <N> --repo modelcontextprotocol/inspector --body \
'**Triage:** Priority **Medium** (total 7)
- Severity 3 — a real feature is broken; workaround is partial
- Urgency 2 — wanted eventually, nothing blocked on it
- Bonuses: +1 `bug` label, +1 reporter set Fields → Priority to High
Board: #28, Status `Incoming` (awaiting maintainer review — no milestone yet).'
Name the bonuses you claimed rather than just summing them — the milestone bonus in particular should be conspicuously absent on a pass-1 triage, and a comment that lists it is a visible sign the approval semantics were misapplied.
Sweeping in the unboarded issues is only the most visible defect class. A board
drifts in several other ways that no single-issue rule catches, so finish a
triage run with the audit below — every check should print 0. A non-zero
count means the board contradicts a rule, not that the rule needs revisiting.
| Check | Invariant | Fix |
|---|---|---|
| Double-boarded | An issue has a card on one board, the one matching its version label | Delete the wrong-board card |
| Non-Issue items | Only issues go on a board — never PRs, never drafts, except a [GHSA-…] draft on #28 | Delete the item |
| No Status | Every card carries a Status | Set one — Incoming if unmilestoned, else by where it actually is |
| GHSA draft missing Status/Priority | An exempted advisory draft still carries both | Set them — /security-advisory |
Incoming with a milestone (#28) | Incoming ⇔ no milestone | Approval was never recorded: move to Todo, or clear the milestone |
| Past Incoming without a milestone (#28) | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming |
| Wrong board for label | v1 → #11, v2 → #28 | Move the card to the right board |
| Not exactly one version label | Every issue carries exactly one of v1/v2 | Apply the missing one, or remove the extra |
| Not exactly one type label | Every issue carries exactly one of the five types | Classify it, or remove the extra |
| No Priority (#28) | Every board item is prioritized | Score it with the rubric |
| Closed, not shipped, still carded | Done means the work shipped | Delete the card |
Open, but carded Done | A card in Done ⇒ its issue is closed | Close the issue, or move the card back |
D=$(mktemp -d); R=modelcontextprotocol/inspector
# --limit must exceed the repo's TOTAL issue count (884 as of 2026-08-05), not just the open ones —
# the last check below reads closed issues' state reasons.
gh issue list --repo $R --state all --limit 2000 \
--json number,state,stateReason,labels,milestone > "$D/i.json"
# item-list truncates SILENTLY past --limit (and a failed call writes nothing); an
# incomplete dump would make every check below lie, so it is deleted and the audit
# fails on the missing file instead.
for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \
--format json --limit 2000 > "$D/b$P.json"
jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \
|| { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; }
done
jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" '
($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""),
lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M
| def own($s): [$s[].items[]
# A DRAFT card has no `.content.repository`, so filtering on equality
# alone drops the very items the "non-Issue" check exists to find.
| select((.content.repository // null) == null or .content.repository==$R)];
def I($n): ($M[($n|tostring)] // null);
def ms($n): (I($n).ms // null);
def lab($n): (I($n).lab // []);
def isopen($n): (I($n).st == "OPEN");
def shipped($n): (I($n).sr == "COMPLETED");
[own($a)[] | select(.content.type=="Issue") | {n:.content.number, s:.status, p:.priority}] as $B28
| [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11
| {
"double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))],
# An advisory draft card is the ONE legitimate non-Issue item (see AGENTS.md).
# The exemption is narrowed three ways, and each one matters: DRAFTS only
# (a GHSA-titled PR is still reported), board #28 ONLY (an advisory has no
# business on #11), and the `[GHSA-` title prefix (a stray draft is still
# reported). Reports the TITLE, since a draft has no number.
"non-Issue on a board": [(own($a)[] | select(.content.type!="Issue"
and ((.content.type=="DraftIssue"
and ((.content.title // "") | startswith("[GHSA-"))) | not))),
(own($b)[] | select(.content.type!="Issue"))]
| map(.content.title // "(untitled)"),
# $B28/$B11 hold only Issue items, so the Status and Priority checks below
# cannot see an advisory draft. Exempting drafts from the check above would
# therefore have made a half-made advisory card invisible to the whole
# audit; this is the narrow replacement.
"GHSA draft missing Status/Priority":
[own($a)[] | select(.content.type=="DraftIssue"
and ((.content.title // "") | startswith("[GHSA-")))
| select(.status==null or .priority==null)
| (.content.title[0:24])],
"no Status": [($B28[], $B11[]) | select(.s==null) | .n],
"Incoming w/ milestone": [$B28[] | select(.s=="Incoming" and ms(.n)!=null) | .n],
"past Incoming, no ms": [$B28[] | select(.s!=null and .s!="Incoming" and .s!="Done"
and isopen(.n) and ms(.n)==null) | .n],
"v1 label on #28": [$B28[] | select(isopen(.n) and (lab(.n)|index("v1"))) | .n],
"v2 label on #11": [$B11[] | select(isopen(.n) and (lab(.n)|index("v2"))) | .n],
"open, not exactly 1 version label":
[$o[0][] | select(.state=="OPEN")
| select(([.labels[].name] | map(select(IN("v1","v2"))) | length) != 1)
| .number],
"open, not exactly 1 type label":
[$o[0][] | select(.state=="OPEN")
| select(([.labels[].name]
| map(select(IN("bug","enhancement","documentation","chore","question")))
| length) != 1)
| .number],
"#28 open, no Priority": [$B28[] | select(.p==null and isopen(.n)) | .n],
"closed unshipped, still carded":
[($B28[], $B11[]) | select(I(.n)!=null and (isopen(.n)|not)
and (shipped(.n)|not)) | .n],
"open, but carded Done": [($B28[], $B11[]) | select(.s=="Done" and isopen(.n)) | .n]
} | to_entries[] | "\(.value|length)\t\(.key)\t\(.value[0:10])"'
Two things the queries must account for, both learned the hard way:
modelcontextprotocol/servers issue. Without the .content.repository filter
it reads as a statusless-card defect, and "fixing" it would mean editing
another repo's tracking.Done is checked from both sides. One direction catches a card parked in
Done for work that never shipped; the other catches an issue still open
under a Done card. The second is not hypothetical here — closing keywords do
not fire on v2/main, so every v2 issue is closed by hand after its PR
merges, and forgetting that step is the normal way this breaks (Copilot). The
other checks cannot see it: they either gate on isopen or skip Done.v1 issue has none it could take — running the
Incoming⇔milestone invariant over board #11 would flag every card on it for a
state it cannot reach. They also read Issue items only, which is what
exempts a [GHSA- advisory draft on #28: it cannot carry a milestone, and its
approval is the advisory's acceptance (/security-advisory step 3),
which lives on the advisory rather than the board. The audit cannot see that,
so a GHSA draft past Incoming is correct once its advisory is accepted and
is not reported.v1 and v2 — which belongs to two lines at once
and shows up in both filtered queries. Same for the five type labels (Copilot).own. A draft card carries no .content.repository,
so a plain equality filter removes it before the "non-Issue" check can see it —
and that check then reports 0 while the invariant it states (no drafts) is
being violated (Copilot). The filter admits an item with no repository and
excludes only cards that name a different one.[GHSA-xxxx-yyyy-zzzz] - … — the one exception AGENTS.md
grants to "no draft cards", and the security-advisory skill is the flow. There
are enough of them open at any time that counting them would pin this check
permanently non-zero, and a check that never prints stops being read at
all. The discriminator is deliberately the and nothing
broader: exempting drafts, or every card whose Status is ,
would let an ordinary stray draft through, which is the defect the check
exists for. So a draft titled anything else is still reported — by title,
since a draft has no issue number to print.
⚠️ Matched on its own it would also exempt a
whose title happens to start — a plausible title for a security fix
— and an advisory draft misfiled on , where the replacement field check
below does not look either, so both checks would read . The exemption is
therefore board #28; #11 still reports
every non-Issue item it carries.
⚠️ and
are built from items only, so the and
checks never see a draft — before the carve-out the non-Issue check was the
thing looking at one, and exempting drafts there alone would have made
a half-made advisory card invisible to the entire audit. Hence
, which reads the item-level
and that exposes for a draft as it does for an issue.Do not "fix" a Done card that is missing a milestone. Cards predating a
rule are not defects to backfill in bulk — the audit exists to stop new drift,
and rewriting settled history destroys the record of when a rule started being
enforced.
0Incoming[GHSA-0DraftIssue[GHSA-$B28$B11Issueno Statusno PriorityGHSA draft missing Status/Priority.status.priorityitem-list$M holds closed issues too — the lookup is built from
gh issue list --state all, which it has to be, because the last check reads
closed issues' state reasons. So isopen is not there to cope with a missing
entry; it is there because the invariants are about open work. A closed
issue legitimately sits in Done with whatever milestone it had, and a check
that did not gate on isopen would report every one of them (Copilot).