LogoSkills

zenhub:sprint-rollover

Carry unfinished issues from the active sprint into the next sprint

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

/zenhub:sprint-rollover โ€” ๋ชป ๋๋‚ธ ์ผ๊ฐ ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ๋กœ ๋„˜๊ธฐ๊ธฐ#

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

์ด๋ฒˆ 2์ฃผ(์Šคํ”„๋ฆฐํŠธ)์— ๋‹ค ๋ชป ๋๋‚ธ ์ผ๊ฐ์„, ์ž๋™์œผ๋กœ ๋‹ค์Œ 2์ฃผ๋กœ ์˜ฎ๊ฒจ ์ฃผ๋Š” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. ๋‹ฌ๋ ฅ์„ ํ•œ ์žฅ ๋„˜๊ธธ ๋•Œ "์ง€๋‚œ ์ฃผ์— ๋ชป ํ•œ ํ•  ์ผ"์„ ์ƒˆ ์ฃผ๊ฐ„ ์นธ์œผ๋กœ ์˜ฎ๊ฒจ ์ ์–ด ์ฃผ๋Š” ์ผ์ด๋ผ๊ณ  ๋ณด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์•ˆ ์˜ฎ๊ธฐ๋ฉด ๋๋‚œ ์Šคํ”„๋ฆฐํŠธ์— ๊ฐ‡ํ˜€์„œ ๋ณด๋“œ์™€ ์ง„ํ–‰ ๊ทธ๋ž˜ํ”„(๋ฒˆ๋‹ค์šด)์—์„œ ์‚ฌ๋ผ์ ธ ๋ฒ„๋ฆฝ๋‹ˆ๋‹ค.

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

  • ์Šคํ”„๋ฆฐํŠธ๊ฐ€ ๋๋‚˜๊ฐˆ ๋•Œ / ๋๋‚œ ์งํ›„, ์ƒˆ ์Šคํ”„๋ฆฐํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์—
  • ์Šคํ”„๋ฆฐํŠธ ํšŒ๊ณ ๋‚˜ ๊ณ„ํš ํšŒ์˜์—์„œ "์ง€๋‚œ๋ฒˆ์— ๋ชป ๋๋‚ธ ๊ฒŒ ๋ญ์˜€์ง€?"๋ฅผ ์ •๋ฆฌํ•  ๋•Œ
  • ๋ฏธ์™„๋ฃŒ ์ผ๊ฐ์ด ๋‹ซํžŒ ์Šคํ”„๋ฆฐํŠธ์— ๊ฐ‡ํ˜€ ๋ณด๋“œ์—์„œ ์•ˆ ๋ณด์ผ ๋•Œ

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

  • ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ์—์„œ ์•„์ง ์•ˆ ๋๋‚œ(์—ด๋ ค ์žˆ๋Š”) ์ด์Šˆ๋งŒ ๊ณจ๋ผ๋ƒ…๋‹ˆ๋‹ค โ€” ์ด๋ฏธ ๋‹ซํžŒ ์ด์Šˆ๋Š” ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค
  • ๊ทธ ์ด์Šˆ๋“ค์„ ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ๋กœ ์˜ฎ๊ธฐ๊ณ , ๋ณด๋“œ์˜ Sprint Backlog ์นธ์œผ๋กœ ์ •๋ฆฌํ•ด ๋‘ก๋‹ˆ๋‹ค
  • ๋„˜๊ธด ์ด์Šˆ ๋ชฉ๋ก๊ณผ ๋„˜์–ด๊ฐ„ ์Šคํ† ๋ฆฌ ํฌ์ธํŠธ ํ•ฉ๊ณ„๋ฅผ ๋ฆฌํฌํŠธ๋กœ ๋ณด์—ฌ ์ค๋‹ˆ๋‹ค
  • ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ๊ฐ€ ๋„ˆ๋ฌด ๊ฝ‰ ์ฐจ๋ฉด(๊ธฐ์กด ์ ์žฌ๋Ÿ‰ + ์ด๋ฒˆ carryover ๊ฐ€ ์šฉ๋Ÿ‰์˜ ~90% ์ดˆ๊ณผ) ๋ฏธ๋ฆฌ ๊ฒฝ๊ณ ํ•ฉ๋‹ˆ๋‹ค
  • ๋‹ค์‹œ ์‹คํ–‰ํ•ด๋„ ์ด๋ฏธ ์˜ฎ๊ธด ๊ฑด ๊ทธ๋Œ€๋กœ ๋‘ก๋‹ˆ๋‹ค(์ค‘๋ณต ์ด๋™ ์—†์Œ)

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

# ๋จผ์ € ๋ฏธ๋ฆฌ๋ณด๊ธฐ โ€” ์‹ค์ œ๋กœ ์˜ฎ๊ธฐ์ง€ ์•Š๊ณ  ๋ฌด์—‡์ด ๋„˜์–ด๊ฐˆ์ง€ ๋ฆฌํฌํŠธ๋งŒ (๊ถŒ์žฅ: ํ•ญ์ƒ ์ด๊ฑธ ๋จผ์ €)
/zenhub:sprint-rollover --dry-run

# ์‹ค์ œ ์‹คํ–‰ โ€” ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ(getUpcomingSprint)๋กœ carryover
/zenhub:sprint-rollover

# ํŠน์ • ์Šคํ”„๋ฆฐํŠธ๋กœ ๋„˜๊ธฐ๊ธฐ (์ด๋ฆ„ ์ผ๋ถ€๋กœ ์ง€์ •)
/zenhub:sprint-rollover --to  " Sprint 24 "
  • --dry-run โ€” ์•„๋ฌด๊ฒƒ๋„ ์˜ฎ๊ธฐ์ง€ ์•Š๊ณ  "๋ฌด์—‡์ด ๋„˜์–ด๊ฐˆ์ง€"๋งŒ ๋ณด์—ฌ ์ค๋‹ˆ๋‹ค. ๋จผ์ € ์ด๊ฑธ๋กœ ํ™•์ธํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
  • --to next|<์ด๋ฆ„> โ€” ๋„˜๊ธธ ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ. ๊ธฐ๋ณธ์€ next(๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ)์ž…๋‹ˆ๋‹ค.

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

  1. ๋‘ ์Šคํ”„๋ฆฐํŠธ ํ™•์ธ โ€” ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ์™€ ๋„˜๊ธธ ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค. ๋‘˜ ์ค‘ ํ•˜๋‚˜๋ผ๋„ ์—†์œผ๋ฉด ์•ˆ์ „ํ•˜๊ฒŒ ๋ฉˆ์ถฅ๋‹ˆ๋‹ค.
  2. ๋ฏธ์™„๋ฃŒ ๊ณจ๋ผ๋‚ด๊ธฐ โ€” ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ ์ด์Šˆ ์ค‘ GitHub์—์„œ ์•„์ง ์—ด๋ ค ์žˆ๋Š”(๋‹ซํžˆ์ง€ ์•Š์€) ๊ฒƒ๋งŒ ์ถ”๋ฆฝ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋‹ซํžŒ ๊ฑด ์™„๋ฃŒ๋กœ ๋ณด๊ณ  ๊ทธ๋Œ€๋กœ ๋‘ก๋‹ˆ๋‹ค.
  3. ์˜ฎ๊ธฐ๊ธฐ โ€” ๊ฐ ์ด์Šˆ๋ฅผ ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ์—์„œ ๋นผ๊ณ  โ†’ ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ์— ๋„ฃ๊ณ  โ†’ ๋ณด๋“œ์˜ Sprint Backlog ์นธ์œผ๋กœ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
  4. ๋ฆฌํฌํŠธ โ€” ๋„˜๊ธด ์ด์Šˆ ๋ชฉ๋ก๊ณผ ํฌ์ธํŠธ ํ•ฉ๊ณ„๋ฅผ ๋ณด์—ฌ ์ฃผ๊ณ , ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ๊ฐ€ ๊ณผ์ ์ด๋ฉด ๊ฒฝ๊ณ ํ•ฉ๋‹ˆ๋‹ค.

์ฐธ๊ณ : "๋‹ซํž˜"์€ ๋ณด๋“œ ์นธ์ด ์•„๋‹ˆ๋ผ GitHub ์ด์Šˆ ์ƒํƒœ๋กœ ํŒ๋‹จํ•ฉ๋‹ˆ๋‹ค. ์™„๋ฃŒ ์ด์Šˆ๋ฅผ Done ๊ฐ™์€ ์นธ์œผ๋กœ ์˜ฎ๊ธฐ๋Š” ์‹์ด ์•„๋‹ˆ๋ผ, ์—ด๋ ค ์žˆ๋Š” ๋ฏธ์™„๋ฃŒ๋งŒ ๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธ๋กœ ๋„˜๊น๋‹ˆ๋‹ค.


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

Triggers#

  • At sprint boundary (end of active sprint / before next sprint starts)
  • During retro/planning to reconcile incomplete work
  • When unfinished issues are stuck in a closed sprint and dropped from the board/burndown

Context Trigger Pattern#

/zenhub:sprint-rollover [--dry-run] [--to next| < name > ]

Options / Actions#

OptionDescriptionDefault
--dry-runReport what would move; perform no mutationsoff (recommended to run first)
--to nextTarget = next sprint (getUpcomingSprint)next
--to <name>Target = open sprint whose name includes <name> (listRecentSprints)โ€”

Selector resolution follows the Sprint Selector Resolution table in rules/zenhub-conventions.md (do not redefine). --to maps to resolveSprint(selector); the source is always the active sprint via getSprint() (no id).

Execution Flow#

// 0. Workspace IDs (conventions: query before any move) + repositoryIds for pipeline reads
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repositoryIds = workspace.githubRepositories.map(r = >   r.id);

// 1. Resolve source (always active) + target (default  " next " ). Fail-closed on either.
const active = await mcp__zenhub__getSprint(); // no id = ACTIVE sprint
const target = await resolveSprint(opts.to ||  " next " ); // see zenhub-conventions Sprint Selector Resolution
if (!active) throw new Error( " ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ ์—†์Œ โ€” getSprint() ๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค. ์Šคํ”„๋ฆฐํŠธ๊ฐ€ ์ง„ํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”. " );
if (!target) throw new Error(`๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ ํ•ด์„ ์‹คํŒจ (--to ${opts.to ||  " next " }). ๋ผ์ด๋ธŒ open: ${(await mcp__zenhub__listRecentSprints()).openSprints.map(s = >   s.name).join( " ,  " )}`);
if (target.id === active.id) throw new Error( " ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ๊ฐ€ ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ์™€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค โ€” carryover ๋Œ€์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค. " );

// 2. Active-sprint issues, keep only UNFINISHED = GitHub state OPEN (closed = done, leave as-is).
//    No getIssue/getChildrenOfParent โ€” fetch via searchLatestIssues by sprint membership.
const inSprint = await mcp__zenhub__searchLatestIssues({ query: `sprint: " ${active.name} " ` });
const unfinished = inSprint.filter(i = >   i.state ===  " OPEN " ); // CLOSED stays put (step 5)

// 3. Per unfinished issue: remove from active โ†’ add to target โ†’ guard pipeline to Sprint Backlog.
const sprintBacklog = workspace.pipelines.find(p = >   p.name ===  " Sprint Backlog " );
if (!sprintBacklog) throw new Error(` ' Sprint Backlog '   ํŒŒ์ดํ”„๋ผ์ธ ์—†์Œ. ๋ผ์ด๋ธŒ: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);

let movedPoints = 0;
const moved = [];
for (const issue of unfinished) {
  if (!opts.dryRun) {
    await mcp__zenhub__removeIssuesFromSprints({ sprintIds: [active.id], issueIds: [issue.id] });
    await mcp__zenhub__addIssuesToSprints({ sprintIds: [target.id], issueIds: [issue.id] });
    await mcp__zenhub__moveIssueToPipeline({ issueId: issue.id, pipelineId: sprintBacklog.id });
  }
  movedPoints += issue.estimate?.value ?? 0; // estimate read = setIssueEstimate signal (Matrix effort axis)
  moved.push({ number: issue.number, title: issue.title, points: issue.estimate?.value ?? 0 });
}

// 4. Capacity warning (B5): read target ' s existing load and warn if post-carryover  >   ~90%.
const targetDetail = await mcp__zenhub__getSprint(target.id);
const existing = targetDetail.totalPoints ?? 0;
const capacity = targetDetail.capacity ?? null; // null = no capacity set โ†’ skip warn
const projected = existing + movedPoints;
const overCapacity = capacity != null  & &   projected  >   capacity * 0.9;

// 5. Report (CLOSED issues untouched).
return {
  dryRun: !!opts.dryRun,
  from: active.name, to: target.name,
  movedCount: moved.length, movedPoints, moved,
  capacity: { existing, carryover: movedPoints, projected, capacity },
  warning: overCapacity
    ? `โš ๏ธ ๋Œ€์ƒ ์Šคํ”„๋ฆฐํŠธ  ' ${target.name} '   ๊ณผ์ : ${projected}/${capacity} pt (carryover ํ›„ 90% ์ดˆ๊ณผ). ์ผ๋ถ€ ์ด์Šˆ๋ฅผ Product Backlog/Icebox ๋กœ ๋‚จ๊ธฐ๋Š” ๊ฒƒ์„ ๊ฒ€ํ† ํ•˜์„ธ์š”.`
    : null,
};

MCP Tool Usage#

// Sprint selector (from rules/zenhub-conventions.md โ€” reference, do not redefine)
async function resolveSprint(selector) {
  if (selector ===  " next " ) return await mcp__zenhub__getUpcomingSprint();
  if (!selector || selector ===  " current " ) return await mcp__zenhub__getSprint(); // active
  const { openSprints } = await mcp__zenhub__listRecentSprints();
  return openSprints.find(s = >   s.name.includes(String(selector)));
}

// Carryover primitives (the gap this command fills โ€” removeIssuesFromSprints was never called elsewhere)
await mcp__zenhub__removeIssuesFromSprints({ sprintIds: [ " < activeSprintId > " ], issueIds: [ " < issueGraphqlId > " ] });
await mcp__zenhub__addIssuesToSprints({ sprintIds: [ " < targetSprintId > " ], issueIds: [ " < issueGraphqlId > " ] });

// Pipeline guard โ€” fail-closed lookup, never an unguarded .id
const sprintBacklog = workspace.pipelines.find(p = >   p.name ===  " Sprint Backlog " );
if (!sprintBacklog) throw new Error(` ' Sprint Backlog '   ํŒŒ์ดํ”„๋ผ์ธ ์—†์Œ. ๋ผ์ด๋ธŒ: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
await mcp__zenhub__moveIssueToPipeline({ issueId:  " < issueGraphqlId > " , pipelineId: sprintBacklog.id });

// Closed-issue check (verify by GitHub state, NOT by a board column โ€” 2-state model)
const closed = await mcp__zenhub__searchClosedIssues({ query: `# < n > ` });

Key Rules#

  1. Source is always active. Carryover source = getSprint() (no id). Target = resolveSprint(--to || "next") = getUpcomingSprint() by default. Never resolve the source via getUpcomingSprint (would skip a sprint).
  2. Fail-closed everywhere. If active, target, or the Sprint Backlog pipeline is missing, throw with the live list โ€” never proceed with an unguarded .id or silently partial-move.
  3. Unfinished = GitHub state OPEN. Judge by GitHub state, never by a board column. Review/QA is not "done". CLOSED issues are complete and are left untouched (step 5) โ€” there is no Done/Closed column to move into.
  4. Idempotent. Re-running is a no-op for already-moved issues: they are no longer in the active sprint, so they fall out of the source set. Safe to re-run.
  5. --dry-run first. Recommend a dry-run before any real move; mutations (removeIssuesFromSprints/addIssuesToSprints/moveIssueToPipeline) are gated on !opts.dryRun.
  6. Points read-only. Sum estimate.value via the existing setIssueEstimate signal (Matrix effort axis); this command reports points but does not change estimates, labels, or impact (labels are immutable post-creation).
  7. Capacity warning (B5). Read target load via getSprint(target.id); warn if existing + carryover > capacity * 0.9. Skip the warning when no capacity is set.
  8. Real tools only. No getIssue/getChildrenOfParent โ€” fetch issues via searchLatestIssues; getIssuesInPipeline (when used) requires repositoryIds from getWorkspacePipelinesAndRepositories().githubRepositories.map(r => r.id).