/cc-dev:zenhub:sprint-rollover — 못 끝낸 일감 다음 스프린트로 넘기기#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-dev:zenhub:sprint-rollover |
| 분류 | 워크플로우 |
| 난이도 | ●●○ 보통 |
| MCP 서버 | zenhub |
한마디로#
이번 2주(스프린트)에 다 못 끝낸 일감을, 자동으로 다음 2주로 옮겨 주는 도구입니다. 달력을 한 장 넘길 때 "지난 주에 못 한 할 일"을 새 주간 칸으로 옮겨 적어 주는 일이라고 보면 됩니다. 안 옮기면 끝난 스프린트에 갇혀서 보드와 진행 그래프(번다운)에서 사라져 버립니다.
누가·언제 쓰나요#
- 스프린트가 끝나갈 때 / 끝난 직후, 새 스프린트를 시작하기 전에
- 스프린트 회고나 계획 회의에서 "지난번에 못 끝낸 게 뭐였지?"를 정리할 때
- 미완료 일감이 닫힌 스프린트에 갇혀 보드에서 안 보일 때
무엇을 해주나요#
- 활성 스프린트에서 아직 안 끝난(열려 있는) 이슈만 골라냅니다 — 이미 닫힌 이슈는 건드리지 않습니다
- 그 이슈들을 다음 스프린트로 옮기고, 보드의
Sprint Backlog칸으로 정리해 둡니다 - 넘긴 이슈 목록과 넘어간 스토리 포인트 합계를 리포트로 보여 줍니다
- 다음 스프린트가 너무 꽉 차면(기존 적재량 + 이번 carryover 가 용량의 ~90% 초과) 미리 경고합니다
- 다시 실행해도 이미 옮긴 건 그대로 둡니다(중복 이동 없음)
어떻게 쓰나요#
# 먼저 미리보기 — 실제로 옮기지 않고 무엇이 넘어갈지 리포트만 (권장: 항상 이걸 먼저)
/cc-dev:zenhub:sprint-rollover --dry-run
# 실제 실행 — 다음 스프린트(getUpcomingSprint)로 carryover
/cc-dev:zenhub:sprint-rollover
# 특정 스프린트로 넘기기 (이름 일부로 지정)
/cc-dev: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#
/cc-dev: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).