| ํญ๋ชฉ | ๋ด์ฉ |
|---|---|
| Invoke | /zenhub:sprint-rollover |
| Category | kobic-workflow |
| Complexity | moderate |
| MCP Servers | zenhub |
/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(๋ค์ ์คํ๋ฆฐํธ)์ ๋๋ค.
์์์ ๋ฌด์จ ์ผ์ด ๋ฒ์ด์ง๋์#
- ๋ ์คํ๋ฆฐํธ ํ์ธ โ ํ์ฑ ์คํ๋ฆฐํธ์ ๋๊ธธ ๋์ ์คํ๋ฆฐํธ๋ฅผ ์ฐพ์ต๋๋ค. ๋ ์ค ํ๋๋ผ๋ ์์ผ๋ฉด ์์ ํ๊ฒ ๋ฉ์ถฅ๋๋ค.
- ๋ฏธ์๋ฃ ๊ณจ๋ผ๋ด๊ธฐ โ ํ์ฑ ์คํ๋ฆฐํธ ์ด์ ์ค GitHub์์ ์์ง ์ด๋ ค ์๋(๋ซํ์ง ์์) ๊ฒ๋ง ์ถ๋ฆฝ๋๋ค. ์ด๋ฏธ ๋ซํ ๊ฑด ์๋ฃ๋ก ๋ณด๊ณ ๊ทธ๋๋ก ๋ก๋๋ค.
- ์ฎ๊ธฐ๊ธฐ โ ๊ฐ ์ด์๋ฅผ ํ์ฑ ์คํ๋ฆฐํธ์์ ๋นผ๊ณ โ ๋์ ์คํ๋ฆฐํธ์ ๋ฃ๊ณ โ ๋ณด๋์
Sprint Backlog์นธ์ผ๋ก ์ ๋ฆฌํฉ๋๋ค. - ๋ฆฌํฌํธ โ ๋๊ธด ์ด์ ๋ชฉ๋ก๊ณผ ํฌ์ธํธ ํฉ๊ณ๋ฅผ ๋ณด์ฌ ์ฃผ๊ณ , ๋์ ์คํ๋ฆฐํธ๊ฐ ๊ณผ์ ์ด๋ฉด ๊ฒฝ๊ณ ํฉ๋๋ค.
์ฐธ๊ณ : "๋ซํ"์ ๋ณด๋ ์นธ์ด ์๋๋ผ 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#
| Option | Description | Default |
|---|---|---|
--dry-run | Report what would move; perform no mutations | off (recommended to run first) |
--to next | Target = 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).--tomaps toresolveSprint(selector); the source is always the active sprint viagetSprint()(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#
- Source is always active. Carryover source =
getSprint()(no id). Target =resolveSprint(--to || "next")=getUpcomingSprint()by default. Never resolve the source viagetUpcomingSprint(would skip a sprint). - Fail-closed everywhere. If
active,target, or theSprint Backlogpipeline is missing,throwwith the live list โ never proceed with an unguarded.idor silently partial-move. - Unfinished = GitHub state OPEN. Judge by GitHub
state, never by a board column.Review/QAis not "done". CLOSED issues are complete and are left untouched (step 5) โ there is noDone/Closedcolumn to move into. - 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.
--dry-runfirst. Recommend a dry-run before any real move; mutations (removeIssuesFromSprints/addIssuesToSprints/moveIssueToPipeline) are gated on!opts.dryRun.- Points read-only. Sum
estimate.valuevia the existingsetIssueEstimatesignal (Matrix effort axis); this command reports points but does not change estimates, labels, or impact (labels are immutable post-creation). - Capacity warning (B5). Read target load via
getSprint(target.id); warn ifexisting + carryover > capacity * 0.9. Skip the warning when no capacity is set. - Real tools only. No
getIssue/getChildrenOfParentโ fetch issues viasearchLatestIssues;getIssuesInPipeline(when used) requiresrepositoryIdsfromgetWorkspacePipelinesAndRepositories().githubRepositories.map(r => r.id).