/cc-dev:zenhub:changelog — ZenHub 릴리스 노트 생성기#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-dev:zenhub:changelog |
| 분류 | 워크플로우 |
| 난이도 | ●●○ 보통 |
| MCP 서버 | zenhub |
한마디로#
지난 출시 이후 "끝난 일(닫힌 이슈)"을 모아서 릴리스 노트를 자동으로 써 주는 도구입니다. 회의록 대신, 영수증을 모아 한 장의 "이번에 한 일" 요약지를 출력해 준다고 보면 됩니다. 커밋 메시지가 아니라 ZenHub에서 닫힌 이슈를 근거(source)로 삼습니다.
누가·언제 쓰나요#
- 새 버전을 배포하기 직전, "이번 릴리스에 뭐가 들어갔지?"를 정리할 때
- 스프린트가 끝나서 그 기간에 마무리한 일을 한 장으로 보고할 때
-
기존 커밋 기반 CHANGELOG가 prefix(
feat:,fix:)에 의존해 들쭉날쭉할 때, 이슈 타입을 기준으로 깔끔하게 정리하고 싶을 때
무엇을 해주나요#
- "어디서부터 어디까지"의 기간(윈도우)을 정해 줍니다 — 마지막 git tag 이후, 또는 지정한 스프린트 기간
- 그 기간에 닫힌 이슈만 모읍니다 (보드 칸이 아니라 GitHub에서 실제로 닫힌 것 기준)
- 이슈 타입·라벨로 자동 분류해 줍니다 — Added(기능 추가), Fixed(버그 수정), Changed(개선), Docs 등
- 각 항목에 연결된 PR 링크를 붙여 추적할 수 있게 해 줍니다
- 결과를 마크다운으로 출력하고, 원하면
CHANGELOG.md맨 위에 새 섹션으로 끼워 넣어 줍니다
어떻게 쓰나요#
# 가장 최근 tag 이후 닫힌 이슈로 릴리스 노트 생성 (출력만)
/cc-dev:zenhub:changelog --dry-run
# 특정 tag 이후로 집계해 CHANGELOG.md 에 prepend
/cc-dev:zenhub:changelog --since-tag v2.13.0 --out CHANGELOG.md
# 현재(활성) 스프린트 기간으로 집계
/cc-dev:zenhub:changelog --since-sprint current
# 이름으로 스프린트 지정
/cc-dev:zenhub:changelog --since-sprint " Sprint 24 "
--since-tag <tag>— 이 tag 이후 닫힌 이슈만 (생략 시 가장 최근 tag)--since-sprint current|next|<이름>— tag 대신 스프린트 기간으로 윈도우 결정--dry-run— 파일을 건드리지 않고 화면에만 출력--out <경로>— 결과를 넣을 파일 (기본: stdout /CHANGELOG.md섹션)
안에서 무슨 일이 벌어지나요#
-
기간 정하기 —
--since-tag면 그 tag의 날짜를,--since-sprint면 스프린트의 시작·종료일을 윈도우로 잡습니다. - 닫힌 이슈 모으기 — ZenHub에서 그 기간에 닫힌 이슈를 검색해 모읍니다.
- 분류하기 — 이슈 타입(Feature/Bug/Task 등)과 라벨을 보고 Added·Fixed·Changed·Docs로 묶습니다.
- PR 연결 — 각 이슈에 연결된 PR을 찾아 링크를 붙입니다.
- 출력/저장 — 마크다운 릴리스 노트를 출력하고,
--dry-run이 아니면 지정 파일 맨 위에 새 섹션으로 끼워 넣습니다.
참고: "닫힘"은 보드의 칸이 아니라 GitHub에서 이슈가 실제로 닫힌 상태를 뜻합니다. (
Done칸은 닫힘이 아닙니다.) 그래서 이 도구는 커밋이 아니라 닫힌 이슈를 source of truth로 씁니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Triggers#
- Before cutting a release — assemble "what shipped" from closed issues
- At sprint end — summarize work completed in the sprint window
- When commit-prefix-based CHANGELOG drifts; switch source of truth to issue type
- Can be surfaced as a
/cc-dev:session:wrapfollow-up on sprint close (see Key Rules)
Context Trigger Pattern#
/cc-dev:zenhub:changelog [--since-tag < tag > ] [--since-sprint current|next| < name > ] [--dry-run] [--out < path > ]Options / Actions#
| Option | Description | Default |
|---|---|---|
--since-tag <tag> | Window start = the tag's date; collect issues closed after it | latest tag (git describe --tags --abbrev=0) |
--since-sprint current|next|<name> | Window = the sprint's start/end (overrides --since-tag) | — |
--dry-run | Print to stdout only; do not write any file | off |
--out <path> | File to prepend the new section into | stdout / CHANGELOG.md |
Execution Flow#
1. Resolve the window (date range)#
// sprint window takes precedence over tag window
let windowStart, windowEnd = new Date().toISOString();
if (opts.sinceSprint) {
const sprint = await resolveSprint(opts.sinceSprint); // see zenhub-conventions " Sprint Selector Resolution "
if (!sprint) throw new Error(`스프린트 ' ${opts.sinceSprint} ' 못 찾음 (current/next/ < name > ).`);
windowStart = sprint.startAt; // sprint 기간 = 윈도우
windowEnd = sprint.endAt ?? windowEnd;
} else {
const tag = opts.sinceTag
?? (await Bash(`git describe --tags --abbrev=0`)).trim(); // 기본: 가장 최근 tag
// tag 날짜 (release 우선, 없으면 git log)
windowStart = (await Bash(
`gh release view ${tag} --json createdAt -q .createdAt 2 > /dev/null `
+ `|| git log -1 --format=%cI ${tag}`
)).trim();
}2. Collect issues closed inside the window#
// searchClosedIssues 는 close 검증에 쓰던 도구 — 여기선 윈도우 집계로 확장.
const raw = await mcp__zenhub__searchClosedIssues({ query: " * " });
const inWindow = raw.filter(i = >
i.closedAt & & i.closedAt > = windowStart & & i.closedAt < = windowEnd
);
// 정확 매칭이 필요하면 #N 은 번호로(부분 문자열 아님): i.number === n3. Group by issue type + label#
const issueTypes = await mcp__zenhub__getIssueTypes();
// 타입/라벨 → 섹션 매핑 (커밋 prefix 대신 이슈 타입이 source of truth)
function sectionOf(issue) {
const type = (issue.issueType?.name ?? " " ).toLowerCase();
const labels = (issue.labels ?? []).map(l = > l.name.toLowerCase());
if (type === " feature " || labels.includes( " feature " ) || labels.includes( " feat " )) return " Added " ;
if (type === " bug " || labels.includes( " bug " ) || labels.includes( " fix " )) return " Fixed " ;
if (type === " task " || labels.includes( " chore " ) || labels.includes( " refactor " )) return " Changed " ;
if (labels.includes( " docs " ) || labels.includes( " documentation " )) return " Docs " ;
return " Other " ;
}
const sections = { Added: [], Fixed: [], Changed: [], Docs: [], Other: [] };
for (const i of inWindow) sections[sectionOf(i)].push(i);4. Backlink the PR(s) for each issue#
for (const i of inWindow) {
// gh 로 이슈에 연결된 PR (timeline 의 cross-reference / closed-by)
const prs = (await Bash(
`gh issue view ${i.number} --json closedByPullRequestsReferences `
+ `-q ' .closedByPullRequestsReferences[]?.url ' 2 > /dev/null`
)).trim().split( " \n " ).filter(Boolean);
i._prLinks = prs; // 비면 PR 백링크 생략
}5. Render markdown + (optionally) prepend#
const version = opts.sinceSprint ?? `since ${opts.sinceTag ?? " latest tag " }`;
const date = windowEnd.slice(0, 10);
let md = `## ${version} — ${date}\n\n`;
for (const [name, items] of Object.entries(sections)) {
if (!items.length) continue;
md += `### ${name}\n`;
for (const i of items) {
const pr = i._prLinks?.length ? ` (${i._prLinks.join( " , " )})` : " " ;
md += `- ${i.title} (#${i.number})${pr}\n`;
}
md += " \n " ;
}
if (opts.dryRun) {
console.log(md); // 파일 안 쓰고 출력만
} else {
const out = opts.out ?? " CHANGELOG.md " ;
const prev = (await Bash(`cat ${out} 2 > /dev/null || true`));
await Write(out, md + (prev ? " \n " + prev : " " )); // 맨 위에 prepend
}MCP Tool Usage#
// 스프린트 해석 — zenhub-conventions " Sprint Selector Resolution " 그대로 재사용 (재정의 금지)
async function resolveSprint(selector) {
if (selector === " next " ) return await mcp__zenhub__getUpcomingSprint();
if (!selector || selector === " current " ) return await mcp__zenhub__getSprint(); // 활성
const { openSprints } = await mcp__zenhub__listRecentSprints();
return openSprints.find(s = > s.name.includes(String(selector)));
}
// 닫힌 이슈 집계 (윈도우 필터는 closedAt 으로)
const closed = await mcp__zenhub__searchClosedIssues({ query: " * " });
// 타입 분류용
const issueTypes = await mcp__zenhub__getIssueTypes();
// 단건 #N 확인이 필요하면 (getIssue 없음) searchLatestIssues 로 조회 후 number 정확 매칭
const hit = (await mcp__zenhub__searchLatestIssues({ query: `#${n}` }))
.find(i = > i.number === n);Key Rules#
- Closed issues are the source of truth, not commit prefixes. Group by
getIssueTypes()+ labels (Feature→Added, Bug→Fixed, Task/refactor→Changed, docs→Docs). This removes dependence onfeat:/fix:commit hygiene. - Respect the 2-state model. "Closed" = GitHub issue state (
searchClosedIssues/closedAt), never aDone/Closedboard column — this workspace has no such pipeline. This command reads closed state; it never closes (close =gh issue close+updateIssue state:CLOSED, defined in/cc-dev:zenhub:manage). #Nis an exact number match, not a substring — filter withi.number === n, neverString(i.number).includes(...), to avoid#1matching#1413.- Window precedence:
--since-sprintoverrides--since-tag. Tag date prefersgh release view, falls back togit log. Filter strictly onclosedAtwithin[windowStart, windowEnd]. - Sprint resolution is shared, not redefined — use
resolveSprintper zenhub-conventions "Sprint Selector Resolution" (current→getSprint(),next→getUpcomingSprint(), num/name→listRecentSprints().openSprints).currentmust NEVER map togetUpcomingSprint. - Only real MCP tools. No
getIssue/getChildrenOfParent— resolve single issues viasearchLatestIssues. Matrix signals (effort=setIssueEstimate, impact=immutableimpact-*label) per zenhub-conventions are read-only context here, not written. --dry-runwrites nothing; without it, prepend the new section to the top of--out(defaultCHANGELOG.md), preserving prior content.- Sprint-close trigger:
/cc-dev:session:wrapmay surface/cc-dev:zenhub:changelog --since-sprint currentas a follow-up when a sprint ends, so the release note is generated from the just-closed sprint window.