LogoSkills

zenhub-sprint-rollover

진행 중인 스프린트의 미완료 이슈를 다음 스프린트로 이월합니다.

/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(다음 스프린트)입니다.

안에서 무슨 일이 벌어지나요#

  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#

/cc-dev: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).