LogoSkills

zenhub:rerank

Global cross-Epic backlog rerank: build one priority ranking and reorder the board

ํ•ญ๋ชฉ๋‚ด์šฉ
Invoke/zenhub:rerank
Categorykobic-workflow
Complexitymoderate
MCP Serverszenhub

/zenhub:rerank โ€” ์ „์—ญ ๋ฐฑ๋กœ๊ทธ ์šฐ์„ ์ˆœ์œ„ ์žฌ์ •๋ ฌ ๋„์šฐ๋ฏธ#

ํ•œ๋งˆ๋””๋กœ#

์—ฌ๋Ÿฌ Epic์— ํฉ์–ด์ง„ ์ผ๊ฐ์„ ํ•œ ์ค„๋กœ ์„ธ์›Œ "์ด๊ฒŒ 1์ˆœ์œ„, ์ €๊ฒŒ 2์ˆœ์œ„" ์ „์ฒด ์ˆœ์„œ๋ฅผ ๋งค๊ฒจ ์ฃผ๋Š” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. ์„œ๋ž๋งˆ๋‹ค ๋”ฐ๋กœ "๋‚ด ์ฑ…์ƒ 1์ˆœ์œ„"๋ฅผ ์ ์–ด ๋‘” ํฌ์ŠคํŠธ์ž‡๋“ค์„, ์‚ฌ๋ฌด์‹ค ์ „์ฒด ๊ธฐ์ค€ ํ•˜๋‚˜์˜ ์šฐ์„ ์ˆœ์œ„ ์ค„๋กœ ๋‹ค์‹œ ์„ธ์›Œ ์ฃผ๋Š” ์ผ์ด๋ผ๊ณ  ๋ณด๋ฉด ๋ฉ๋‹ˆ๋‹ค.

๋ˆ„๊ฐ€ยท์–ธ์ œ ์“ฐ๋‚˜์š”#

  • ์„œ๋กœ ๋‹ค๋ฅธ ์„ธ์…˜ยท๋‹ค๋ฅธ Epic์—์„œ ๋งŒ๋“  ์ด์Šˆ๋“ค์ด ๊ฐ์ž "P0"๋ฅผ ๋‹ฌ๊ณ  ์žˆ์–ด, ์ง„์งœ ๋ฌด์—‡๋ถ€ํ„ฐ ํ• ์ง€ ํ—ท๊ฐˆ๋ฆด ๋•Œ
  • ์Šคํ”„๋ฆฐํŠธ ๊ณ„ํš ์ „, ๋ฐฑ๋กœ๊ทธ ์ „์ฒด๋ฅผ ํ•œ ๊ธฐ์ค€์œผ๋กœ ์ค„ ์„ธ์šฐ๊ณ  ์‹ถ์„ ๋•Œ
  • PM/ํŒ€ ๋ฆฌ๋“œ๊ฐ€ ํŒ€๋ณ„๋กœ P0๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์ง€ ์•Š์€์ง€(๊ณผ๋ถ€ํ•˜) ํ•œ๋ˆˆ์— ๋ณด๊ณ  ์‹ถ์„ ๋•Œ

๋ฌด์—‡์„ ํ•ด์ฃผ๋‚˜์š”#

  • ๋ฐฑ๋กœ๊ทธ ์นธ(Product Backlog / Sprint Backlog / Icebox)์˜ ๋ชจ๋“  ์ด์Šˆ๋ฅผ ๋ชจ์•„ ํ•˜๋‚˜์˜ ์ „์—ญ ์ˆœ์œ„๋กœ ์ •๋ ฌํ•ฉ๋‹ˆ๋‹ค (tier โ†’ impact โ†’ ์˜์กด์„ฑ ์ˆœ)
  • ๋ณด๋“œ์˜ ์นด๋“œ ์œ„์น˜๋ฅผ ๊ทธ ์ˆœ์„œ์— ๊ฐ€๊น๊ฒŒ ๋‹ค์‹œ ๋ฐฐ์น˜ํ•ฉ๋‹ˆ๋‹ค (์œ„์—์„œ ์•„๋ž˜๋กœ = ๋†’์€ ์šฐ์„ ์ˆœ์œ„)
  • ํŒ€๋ณ„๋กœ ๋ฌถ์–ด ๋ณด์—ฌ ์ฃผ๊ณ , P0๊ฐ€ ๊ณผ๋„ํ•˜๊ฒŒ ๋ชฐ๋ฆฐ ํŒ€์— ๊นƒ๋ฐœ์„ ์„ธ์›Œ ์ค๋‹ˆ๋‹ค
  • ์ˆœ์œ„ ๊ทผ๊ฑฐ๋ฅผ .pipeline/ranking.md ํ‘œ(์ˆœ์œ„ยท์ด์Šˆ๋ฒˆํ˜ธยทtierยทimpactยท๊ทผ๊ฑฐ)๋กœ ๋‚จ๊น๋‹ˆ๋‹ค

์ค‘์š”: ZenHub ๋ผ๋ฒจ์€ ํ•œ๋ฒˆ ๋‹ฌ๋ฉด ๋ชป ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด ๋„๊ตฌ๋Š” ์šฐ์„ ์ˆœ์œ„ ๋ผ๋ฒจ์„ ๊ณ ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ์žฌ์ •๋ ฌ์€ "๋ณด๋“œ ์นธ ์œ„์น˜ + ranking.md ํ‘œ"๋กœ๋งŒ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

์–ด๋–ป๊ฒŒ ์“ฐ๋‚˜์š”#

# ๊ธฐ๋ณธ: ๋ฐฑ๋กœ๊ทธ 3์ข…์„ ์ „์—ญ ์ˆœ์œ„๋กœ ์žฌ์ •๋ ฌ
/zenhub:rerank

# ๋ณด๋“œ๋Š” ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ณ  ์ˆœ์œ„๋งŒ ๋ฏธ๋ฆฌ๋ณด๊ธฐ
/zenhub:rerank --dry-run

# impact ์šฐ์„ ์œผ๋กœ ์ •๋ ฌ (๊ธฐ๋ณธ์€ tier ์šฐ์„ , impact๋Š” ๋™์  ์ฒ˜๋ฆฌ)
/zenhub:rerank --by impact
  • --scope backlog|all โ€” backlog(๊ธฐ๋ณธ): Product/Sprint Backlog + Icebox 3์ข…. all: ๋ฏธ๋ฐฐ์น˜ ์ด์Šˆ๊นŒ์ง€ ํฌํ•จ
  • --dry-run โ€” ๋ณด๋“œ๋ฅผ ์˜ฎ๊ธฐ์ง€ ์•Š๊ณ  ์ˆœ์œ„ ๊ฒฐ๊ณผ๋งŒ ์ถœ๋ ฅ
  • --by impact|tier โ€” ์ •๋ ฌ ๊ธฐ์ค€. ๊ธฐ๋ณธ tier(p0>p1>p2) ํ›„ impact๋กœ ๋™์  ์ฒ˜๋ฆฌ, impact๋ฉด impact ์šฐ์„ 

์•ˆ์—์„œ ๋ฌด์Šจ ์ผ์ด ๋ฒŒ์–ด์ง€๋‚˜์š”#

  1. ์ˆ˜์ง‘ โ€” ์›Œํฌ์ŠคํŽ˜์ด์Šค์—์„œ ์ €์žฅ์†ŒยทํŒŒ์ดํ”„๋ผ์ธ์„ ์กฐํšŒํ•˜๊ณ , ๋ฐฑ๋กœ๊ทธ 3๊ฐœ ์นธ์˜ ๋ชจ๋“  ์ด์Šˆ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
  2. ์‹ ํ˜ธ ์ฝ๊ธฐ โ€” ๊ฐ ์ด์Šˆ์˜ ์šฐ์„ ์ˆœ์œ„(p-๋ผ๋ฒจ), impact ๋ผ๋ฒจ, ์ถ”์ •์น˜(estimate), ๋ถ€๋ชจ(Epic)๋ฅผ ๋ชจ์๋‹ˆ๋‹ค.
  3. ์ „์—ญ ์ •๋ ฌ โ€” tier โ†’ impact โ†’ ์˜์กด์„ฑ ์ˆœ์œผ๋กœ ํ•œ ์ค„ ์ˆœ์œ„๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.
  4. ๋ณด๋“œ ๋ฐ˜์˜ โ€” P0๋ถ€ํ„ฐ ์ฐจ๋ก€๋กœ ์นด๋“œ๋ฅผ ๋‹ค์‹œ ์ด๋™์‹œ์ผœ ๋ณด๋“œ ์œ„โ†’์•„๋ž˜ ์ˆœ์„œ๋ฅผ ์ˆœ์œ„์— ๋งž์ถฅ๋‹ˆ๋‹ค. (๋ผ๋ฒจ์€ ์•ˆ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค.)
  5. ํŒ€ ๋ทฐ โ€” ํŒ€๋ณ„๋กœ ๋ฌถ์–ด ๋ณด์—ฌ ์ฃผ๊ณ , P0๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์€ ํŒ€์— ๊ฒฝ๊ณ  ํ‘œ์‹œ๋ฅผ ๋‹ต๋‹ˆ๋‹ค.
  6. ๊ธฐ๋ก โ€” .pipeline/ranking.md์— ์ˆœ์œ„ ํ‘œ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. (--dry-run์ด๋ฉด ํ™”๋ฉด ์ถœ๋ ฅ๋งŒ.)

โš™๏ธ ์ƒ์„ธ ์˜ต์…˜ยท์‹คํ–‰ ๋ช…์„ธ (๊ฐœ๋ฐœ์ž / AI ์—์ด์ „ํŠธ์šฉ)

Triggers#

  • When cross-Epic / cross-team issues each carry their own "P0" and no single global order exists
  • Before sprint planning, to linearize the whole backlog under one ranking
  • When a PM wants a team-grouped view and over-loaded-P0 flags

Context Trigger Pattern#

/zenhub:rerank [--scope backlog|all] [--dry-run] [--by impact|tier]

Options / Actions#

OptionValuesDefaultDescription
--scopebacklog | allbacklogbacklog: Product Backlog + Sprint Backlog + Icebox. all: also pulls unassigned issues via searchLatestIssues
--dry-runflagoffCompute + print ranking only; no moveIssueToPipeline, no file write
--byimpact | tiertierSort key. tier = p0>p1>p2 then impact tiebreak; impact = impact first
--include-epicsflagoffAlso rank Epic/Project-level issues (default: ranked separately from Story-level, never mixed into the same list โ€” see "Level Separation" below)

Level Separation: these backlog pipelines can hold both Epic/Project (L2-3) and Story/Feature/Bug/Task (L4) issues at once (per rules/zenhub-conventions.md's placement matrix). Ranking them on one shared list would pit a whole Epic against a single Story on the same priority axis, which isn't a meaningful comparison. By default this command ranks Story-level issues only (its stated purpose โ€” "์—ฌ๋Ÿฌ Epic์— ํฉ์–ด์ง„ ์ผ๊ฐ์„ ํ•œ ์ค„๋กœ ์„ธ์šด๋‹ค" โ€” is about Story-level work); Epic/Project issues are collected but reported in a separate section, not interleaved into the Story ranking. Pass --include-epics to rank them too, still in their own separate list.

Execution Flow#

1. Resolve repos + pipelines (fail-closed)#

const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoIds = workspace.githubRepositories.map(r = >   r.id);
if (repoIds.length === 0) throw new Error( " ์ €์žฅ์†Œ 0๊ฐœ โ€” ๋ผ์ด๋ธŒ ์›Œํฌ์ŠคํŽ˜์ด์Šค ํ™•์ธ ํ•„์š” " );

// 3 backlog pipelines, every find() must be fail-closed (never an unguarded .id)
const want = [ " Product Backlog " ,  " Sprint Backlog " ,  " Icebox " ];
const backlogPipelines = want.map(name = >   {
  const p = workspace.pipelines.find(p = >   p.name === name);
  if (!p) throw new Error(`ํŒŒ์ดํ”„๋ผ์ธ  " ${name} "   ์—†์Œ. ๋ผ์ด๋ธŒ: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
  return p;
});
// NOTE: there is NO  " Done " / " Closed "   column. Reranking never closes issues
// and never moves to a Done column โ€” see  " Issue Closure Policy "   in rules/zenhub-conventions.md.

2. Collect issues + signals#

let issues = [];
for (const p of backlogPipelines) {
  const res = await mcp__zenhub__getIssuesInPipeline({ pipelineId: p.id, repositoryIds: repoIds });
  issues.push(...res.issues.map(i = >   ({ ...i, _pipeline: p.name })));
}
// --scope all: also pull unassigned/loose issues (no getIssue/getChildrenOfParent exist)
if (scope ===  " all " ) {
  const loose = await mcp__zenhub__searchLatestIssues({ query:  " no:pipeline "   });
  issues.push(...loose.issues.map(i = >   ({ ...i, _pipeline: null })));
}

// Per-issue signals from labels/estimate/parent (labels are IMMUTABLE โ€” read only)
const tierRank = {  " p0 " : 0,  " p1 " : 1,  " p2 " : 2 };          // priority label โ†’ rank
const impactRank = {  " impact-high " : 0,  " impact-med " : 1,  " impact-low " : 2 };
const sig = issues.map(i = >   {
  const labels = (i.labels || []).map(l = >   l.name);
  return {
    id: i.id, number: i.number, title: i.title, pipeline: i._pipeline,
    level: i.issueType?.level ?? null,                      // hierarchy level (1=Initiative..5=Sub-task) โ€” see Level Separation
    tier: labels.find(n = >   tierRank[n] !== undefined) ??  " p2 " ,
    impact: labels.find(n = >   impactRank[n] !== undefined) ??  " impact-low " ,
    estimate: i.estimate?.value ?? null,                  // effort signal (setIssueEstimate)
    parentId: i.parent?.id ?? null,                        // dependency / Epic grouping
  };
});

// Level Separation (see Options table): split Story-level (L4) from Epic/Project-level (L2-3)
// BEFORE sorting, so the two are never compared on the same priority axis.
const storyLevelSig = sig.filter(s = >   s.level == null || s.level  > = 4);
const upperLevelSig = sig.filter(s = >   s.level != null  & &   s.level  <   4);

3. Global sort (tier โ†’ impact โ†’ dependency)#

const by = opts.by ??  " tier " ;
const cmp = (a, b) = >   {
  const t = (x) = >   tierRank[x.tier] ?? 9;
  const m = (x) = >   impactRank[x.impact] ?? 9;
  if (by ===  " impact " ) return (m(a) - m(b)) || (t(a) - t(b)) || depCmp(a, b);
  return (t(a) - t(b)) || (m(a) - m(b)) || depCmp(a, b);   // default: tier first, impact tiebreak
};
// Level Separation: Story-level (L4) is THE ranking this command reports/moves by default.
// Epic/Project-level (L2-3) is sorted into its own separate list โ€” never merged with storyLevelSig,
// so an Epic never competes against a single Story on the same priority axis.
storyLevelSig.sort(cmp);
if (opts.includeEpics) upperLevelSig.sort(cmp);            // only when --include-epics is passed
// depCmp: issues whose parent appears earlier (blockers/Epics) rank ahead; stable otherwise.

4. Reflect onto board (moveIssueToPipeline has NO position arg)#

// moveIssueToPipeline cannot set an index. To approximate top-to-bottom order,
// re-move issues in DESCENDING priority (P0 first) so the highest land on top.
// Labels are immutable, so the durable rerank signal = pipeline membership + ranking.md.
// Level Separation: only storyLevelSig moves by default โ€” Epic/Project issues are left
// untouched unless --include-epics is passed, and even then move as their own pass
// (never interleaved with Story issues in the same sort/move sequence).
if (!opts.dryRun) {
  const movable = opts.includeEpics ? [...storyLevelSig, ...upperLevelSig] : storyLevelSig;
  for (const s of movable) {                                 // each sub-list already sorted P0-first
    const target = backlogPipelines.find(p = >   p.name === s.pipeline);
    if (!target) throw new Error(`ํƒ€๊นƒ ํŒŒ์ดํ”„๋ผ์ธ  " ${s.pipeline} "   ๋ฏธํ•ด์„. ๋ผ์ด๋ธŒ: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
    await mcp__zenhub__moveIssueToPipeline({ issueId: s.id, pipelineId: target.id });
  }
}

5. Team view + over-P0 flag#

const team = await mcp__zenhub__getTeamMembers();           // group ranked issues per assignee/team
// flag any team holding  >   N p0 issues (default N=3) as overloaded in the report.

6. Persist ranking#

// Write .pipeline/ranking.md with TWO sections (never merged into one table):
//   ## Story-level ranking   โ€” storyLevelSig, columns: rank | #issue | tier | impact | reason
//   ## Epic/Project ranking  โ€” upperLevelSig, same columns (present only when --include-epics)
// --dry-run โ†’ print the same tables to stdout, write nothing.

MCP Tool Usage#

// Repos + pipelines (source of repoIds and pipeline.id)
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoIds = workspace.githubRepositories.map(r = >   r.id);

// Backlog contents (repositoryIds REQUIRED)
await mcp__zenhub__getIssuesInPipeline({ pipelineId, repositoryIds: repoIds });

// Loose issues for --scope all (no getIssue / getChildrenOfParent exist)
await mcp__zenhub__searchLatestIssues({ query:  " #1413 "   });   // or  " parent: < graphqlId > " 

 // Reorder approximation (no position param)
await mcp__zenhub__moveIssueToPipeline({ issueId, pipelineId });

// Team grouping
await mcp__zenhub__getTeamMembers();

Key Rules#

  1. Labels are immutable โ€” updateIssue cannot change priority/impact labels. Reranking is expressed ONLY via pipeline position + .pipeline/ranking.md, never by relabeling.
  2. moveIssueToPipeline has no position arg โ€” approximate top-to-bottom by re-moving in P0-first (descending priority) order.
  3. Every pipelines.find() is fail-closed โ€” throw with the live pipeline list; never dereference an unguarded .id.
  4. getIssuesInPipeline requires repositoryIds โ€” always pass workspace.githubRepositories.map(r => r.id).
  5. Respect the 2-state model โ€” rerank never closes issues and never moves to a Done/Closed column (there is none). Closing remains gh issue close + updateIssue(state:CLOSED) per the "Issue Closure Policy" in rules/zenhub-conventions.md.
  6. Reference, don't redefine โ€” effort/impact signals follow "Matrix Signals" (setIssueEstimate + immutable impact-* label) and any sprint scoping follows "Sprint Selector Resolution" in rules/zenhub-conventions.md.
  7. --dry-run โ€” compute and print the ranking only; perform no moveIssueToPipeline calls and no file writes.
  8. Level Separation โ€” Story-level (L4: Feature/Bug/Task) issues rank and move by default. Epic/Project-level (L2-3) issues are collected but sorted/reported in their own separate list, never merged into the Story comparison โ€” an Epic and a single Story are not commensurable on the same priority axis. Epic/Project issues only move with --include-epics, and even then as their own separate pass.