issue-triage

GitHub

自动化执行未分类Issue的初步筛选与分类。通过两轮扫描识别无看板卡片的Issue,自动添加版本和类型标签,将其加入对应看板并设置状态为Incoming。依据优先级规则评分并记录评论,最后执行看板完整性审计,确保流程规范。

.claude/skills/issue-triage/SKILL.md modelcontextprotocol/inspector

Trigger Scenarios

triage new issues scoring an issue's Priority

Install

npx skills add modelcontextprotocol/inspector --skill issue-triage -g -y
More Options

Non-standard path

npx skills add https://github.com/modelcontextprotocol/inspector/tree/main/.claude/skills/issue-triage -g -y

Use without installing

npx skills use modelcontextprotocol/inspector@issue-triage

指定 Agent (Claude Code)

npx skills add modelcontextprotocol/inspector --skill issue-triage -a claude-code -g -y

安装 repo 全部 skill

npx skills add modelcontextprotocol/inspector --all -g -y

预览 repo 内 skill

npx skills add modelcontextprotocol/inspector --list

SKILL.md

Frontmatter
{
    "name": "issue-triage",
    "description": "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 invariants. Use for \"triage new issues\" or when scoring an issue's Priority.",
    "disable-model-invocation": false
}

Triaging issues

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.

Pass 1 — sweep them onto the board (no approval implied)

For each open issue with no card:

  1. Apply the version label (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.
  2. Add it to the board for that version — v2 → #28, v1 → #11.
  3. Set Status to Incoming.
  4. Set Priority with the rubric below (v2 only), and record the score in a comment. This is an assessment, not an approval — it's how the queue gets ordered for the maintainer who reviews it next.
  5. Leave the milestone unset. Nobody has committed to shipping it yet, and an empty milestone is precisely what marks it as awaiting review.

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.

Finding the unboarded ones

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"

Pass 2 — approve what should ship

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.

The priority rubric

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:

  • Carries a bug or security-related label
  • Linked to a milestone — i.e. already approved. This is the re-scoring case: an issue being scored in a pass-1 triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing.
  • High engagement (many comments or reactions)
  • Assigned to someone
  • A sub-issue of a larger epic
  • The reporter set Fields → 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.

Trust boundary: who can set what

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:

  • It counts for something. Someone flagging their own issue is real information about how much it hurts them.
  • It cannot decide an outcome. The bonus is capped, identical for 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.
  • Never map the value across. A reporter selecting 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.

Recording the score

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.

The board audit

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:

  • Filter by repository. These are org projects and can hold cards from any repo in the org — board #11 currently carries one 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.
  • The two milestone checks are #28-only. Every milestone in this repo is a v2 release bucket, so a 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.
  • Count the labels; don't test for presence. The invariant is exactly one, so a predicate that only asks "is any version label present" passes an issue carrying both v1 and v2 — which belongs to two lines at once and shows up in both filtered queries. Same for the five type labels (Copilot).
  • Keep drafts inside 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.
  • Advisory drafts are carved out of that check by TITLE, not by type. A GitHub security advisory is private until it is published, so it is tracked by a draft card titled [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 0 stops being read at all. The discriminator is deliberately the title prefix and nothing broader: exempting all drafts, or every card whose Status is Incoming, 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. ⚠️ The title prefix alone is not enough, because a title is not a type and not a board. Matched on its own it would also exempt a pull request whose title happens to start [GHSA- — a plausible title for a security fix — and an advisory draft misfiled on #11, where the replacement field check below does not look either, so both checks would read 0. The exemption is therefore DraftIssue and [GHSA- and board #28; #11 still reports every non-Issue item it carries. ⚠️ The exemption had to come with a replacement check. $B28 and $B11 are built from Issue items only, so the no Status and no Priority checks never see a draft — before the carve-out the non-Issue check was the only thing looking at one, and exempting drafts there alone would have made a half-made advisory card invisible to the entire audit. Hence GHSA draft missing Status/Priority, which reads the item-level .status and .priority that item-list exposes for a draft as it does for an issue.
  • $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).

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.

Version History

  • 1e31c78 Current 2026-09-28 17:12

    修复项目列表查询截断导致的误判问题:增加对item-list完整性的检查,失败时中止操作;优化ID清理逻辑防止残留;调整查询限制以确保数据准确性。

  • 2e90a62 2026-09-23 06:47

Same Skill Collection

.claude/skills/board-ops/SKILL.md
.claude/skills/issue-create/SKILL.md
.claude/skills/local-dev/SKILL.md
.claude/skills/pr-flow/SKILL.md
.claude/skills/pre-push-gate/SKILL.md
.claude/skills/project-structure/SKILL.md
.claude/skills/release/SKILL.md
.claude/skills/security-advisory/SKILL.md
.claude/skills/test-servers/SKILL.md
.claude/skills/testing/SKILL.md

Metadata

Files
0
Version
1e31c78
Hash
c5145d81
Indexed
2026-09-23 06:47

ホーム - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-09-30 06:14
浙ICP备14020137号-1