/cc-dev:zenhub:manage — ZenHub 이슈·PR 상태 정리 도우미#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-dev:zenhub:manage |
| 분류 | 워크플로우 |
| 난이도 | ●○○ 간단 |
| MCP 서버 | zenhub |
한마디로#
작업 진행 상황에 맞춰 ZenHub 보드의 카드(이슈)를 알맞은 칸으로 옮기고, 코드 변경(PR)과 이슈를 서로 연결해 주는 도구입니다. 칸반 보드에서 포스트잇을 "진행 중 → 검토 중 → 완료"로 옮겨 붙이는 일을 대신 해 준다고 보면 됩니다.
누가·언제 쓰나요#
- PR(코드 변경 묶음)을 만든 뒤, 그 PR을 관련 이슈와 연결해야 할 때
- 작업 단계가 바뀌어서 보드의 이슈 위치(파이프라인)를 옮겨야 할 때
- 작업을 시작했을 때, 리뷰를 요청할 때, 병합(merge)이 끝났을 때
무엇을 해주나요#
- PR과 이슈를 연결해 줍니다 (GitHub의
Closes #이슈번호키워드 활용) - 이슈를 적절한 보드 칸으로 이동시켜 줍니다 — 예:
In Progress,Review/QA - 이슈와 PR의 현재 상태를 확인해 줍니다
- 병합 후에는 이슈가 자동으로 닫혔는지 확인하고, 안 닫혔으면 대신 닫아 줍니다
-
하위 작업이 남았는데도 닫혀 버린 상위 작업을 찾아 다시 열어 줍니다 (
sync-closed) — 자동 파이프라인이 상위 작업을 앞질러 닫아 버린 경우의 복구 경로입니다 -
아무도 하고 있지 않은데 "진행 중"에 남아 있는 이슈를 찾아 대기 칸으로 되돌립니다 (
sweep-stale-claims) — 세션이 죽으면 그 이슈는 다른 에이전트가 집어들 수 없는 상태로 남습니다. 이 명령이 그것을 한 번에 정리합니다 -
GitHub에서는 이미 닫혔는데 ZenHub에는 아직 반영 안 된 이슈를 찾아 한 번에 맞춰 줍니다 (
sync-closed) —/cc-dev:run세션을 거치지 않고 GitHub에서 직접 머지했거나 수동으로 이슈를 닫은 경우에도, 매번 ZenHub 보드에서 직접 찾아 닫을 필요 없이 이 명령 한 번으로 정리됩니다 -
보드의 칸 목록은 프로젝트마다 다르므로, 처음 실행할 때 자동으로 조회합니다 (대표 칸: New Issues, Icebox, Product Backlog, Sprint Backlog, In Progress, Review/QA —
Done/Closed칸은 없음, 닫힘은 GitHub state)
어떻게 쓰나요#
# PR을 이슈에 연결
/cc-dev:zenhub:manage link-pr --pr 1627 --issue 1413
# 이슈를 특정 보드 칸으로 이동
/cc-dev:zenhub:manage move --issue 1413 --to " Review/QA "
# 이슈·PR 상태 확인
/cc-dev:zenhub:manage status --issue 1413
# GitHub에서 닫힌 이슈 중 ZenHub에 반영 안 된 것들을 일괄 동기화 (최근 7일 기본)
/cc-dev:zenhub:manage sync-closed
# 특정 이슈 하나만 동기화
/cc-dev:zenhub:manage sync-closed --issue 1413
# 조회 기간 조정
/cc-dev:zenhub:manage sync-closed --since 30d
link-pr— PR과 이슈를 연결합니다-
move --to "칸 이름"— 이슈를 해당 보드 칸으로 옮깁니다 (예:"In Progress","Review/QA") status— 이슈나 PR의 현재 상태를 보여 줍니다-
sync-closed— GitHubclosed상태인데 ZenHub가 아직Closed로 못 잡은 이슈를 찾아 강제로 맞춥니다. 인자 없이 실행하면 최근 7일 내 닫힌 이슈를 전부 훑고,--issue로 하나만 콕 집어 확인할 수도 있습니다. 덧붙여, 하위 작업이 열려 있는데도 닫혀 있는 상위 작업(Epic 등)을 찾아 자동으로 다시 열고 어떤 하위 작업이 남았는지 코멘트로 남깁니다.
안에서 무슨 일이 벌어지나요#
작업 흐름에 맞춰 보통 다음 순서로 진행됩니다.
- 작업 시작 — 이슈를
In Progress칸으로 옮깁니다. -
PR 생성 후 — PR 본문에
Closes #이슈번호를 넣어 GitHub가 자동 연결하게 하고, 이슈를Review/QA칸으로 옮깁니다. -
병합 후 —
Closes #키워드 덕분에 GitHub 이슈가 자동으로 닫히고 ZenHub도Closed로 동기화됩니다. (Done칸으로는 옮기지 않습니다 —Done은 "닫힘"과 다릅니다.) 자동으로 닫히지 않았으면 직접 닫아 마무리합니다.
참고: ZenHub에서는 PR을 이슈의 하위 항목으로 둘 수 없습니다. 그래서 GitHub의
Closes #키워드로 연결하고, 상태 관리는 보드 칸 이동으로 합니다. 그리고 Initiative > Project > Epic > Feature/Bug/Task > Sub-task 계층 구조를 유지합니다(요청 규모에 따라 Initiative/Project/Epic은 생략될 수 있습니다).
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Triggers#
- When issue linking is needed after PR creation
- When pipeline state updates are needed
- On work progress state changes
Context Trigger Pattern#
/cc-dev:zenhub:manage {action} [--options]Actions#
| Action | Description | Example |
|---|---|---|
link-pr | Link PR to issue | /cc-dev:zenhub:manage link-pr --pr 1627 --issue 1413 |
move | Move issue to pipeline | /cc-dev:zenhub:manage move --issue 1413 --to "Review/QA" |
status | Check issue/PR status | /cc-dev:zenhub:manage status --issue 1413 |
sync-closed | Reconcile GitHub-closed-but-ZenHub-not-yet-Closed issues + reopen containers that were closed over open children (Parent Closure Invariant), standalone (no PR/merge required) | /cc-dev:zenhub:manage sync-closed [--issue N | --since 7d] [--repo owner/name] |
sweep-stale-claims | 크래시·강제 종료로 In Progress 에 잔류한 이슈를 holding 으로 되돌린다 — 낡은 점유의 유일한 사후 청소 경로 | /cc-dev:zenhub:manage sweep-stale-claims [--dry-run] [--ttl 4h] [--repo owner/name] |
Pipeline Query#
Pipeline IDs differ per project workspace. Dynamically queried on first call:
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
// workspace.pipelines → [{ id, name }, ...]
// Live pipelines (6): New Issues, Icebox, Product Backlog, Sprint Backlog, In Progress, Review/QA
// (no " Done " / " Closed " column — close via gh issue close + updateIssue state:CLOSED)Workflow Patterns#
1. On Work Start#
# Move issue to In Progress
/cc-dev:zenhub:manage move --issue {issue_number} --to " In Progress "2. After PR Creation#
# Include " Closes #{issue_number} " in PR body (GitHub auto-link)
# Move issue to Review/QA
/cc-dev:zenhub:manage move --issue {issue_number} --to " Review/QA "3. After Merge (merge = Close)#
# Issue auto-closes via " Closes # " keyword in PR body → ZenHub syncs to Closed.
# Never move to Done (Done ≠ closed). Verify + fallback:
# gh issue view {n} --json state → if not CLOSED: gh issue close {n} --reason completed
# searchClosedIssues " #{n} " → if missing: updateIssue state:CLOSED4. sync-closed — Standalone Reconciliation (no PR/merge in this session)#
The pattern in §3 only runs when this skill itself drives the merge. Issues closed by any other path — a teammate merging a PR directly on GitHub, gh issue close run outside a /cc-dev:run session, a hierarchical merge whose base isn't the default branch (see zenhub-conventions.md → "Issue Closure Policy") — never trigger it, so ZenHub silently drifts from GitHub. sync-closed is the same reconciliation logic, invocable on demand instead of only as a merge side-effect:
/cc-dev:zenhub:manage sync-closed # scan issues closed on GitHub in the last 7 days (default)
/cc-dev:zenhub:manage sync-closed --since 30d # widen the lookback window
/cc-dev:zenhub:manage sync-closed --issue 1413 # check/fix exactly one issue
/cc-dev:zenhub:manage sync-closed --repo owner/name # target a repo other than the current cwd ' sAlgorithm:
// 1. Resolve the scan set
const repo = args.repo ?? (await Bash(`gh repo view --json nameWithOwner -q .nameWithOwner`)).trim();
let candidates;
if (args.issue) {
candidates = [{ number: Number(args.issue) }];
} else {
const since = parseSinceToISODate(args.since ?? " 7d " ); // e.g. " 7d " → 7 days ago, ISO date
const raw = await Bash(
`gh issue list --repo ${repo} --state closed --search " closed: > =${since} " --json number,title,closedAt --limit 200`
);
candidates = JSON.parse(raw);
// No silent cap: if exactly 200 came back, the window likely truncated — warn and suggest a narrower --since.
if (candidates.length === 200) log(`⚠️ 200개 상한 도달 — --since를 좁혀 재실행 권장`);
}
// 2. Reconcile each against ZenHub
const results = { synced: [], alreadyOk: [], notFoundInZenhub: [] };
for (const { number: n } of candidates) {
const closedInZenhub = await mcp__zenhub__searchClosedIssues({ query: `#${n}` });
if (closedInZenhub.find(i = > i.number === n)) {
results.alreadyOk.push(n);
continue;
}
const latest = await mcp__zenhub__searchLatestIssues({ query: `#${n}` });
const match = latest.find(i = > i.number === n);
if (!match) {
results.notFoundInZenhub.push(n); // not tracked by ZenHub at all — nothing to sync
continue;
}
await mcp__zenhub__updateIssue({ issueId: match.id, state: " CLOSED " });
results.synced.push(n);
}
// 3. ⛔ Parent Closure Invariant 점검 — " 닫힌 부모 + 열린 자식 " 조합을 찾아 복구한다.
// rules/zenhub-conventions.md → " Parent Closure Invariant " / " Child Enumeration Contract "
// ZenHub 동기화(2번)는 " 닫힌 게 맞다 " 를 전제로 하지만, 애초에 닫히면 안 되는 이슈도 있다.
results.reopened = [];
results.childLookupFailed = [];
// fail-closed 파이프라인 ID 해석 — 못 찾으면 throw (undefined 를 move 에 넘겨 무음 누락 금지)
const _ws = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const _inProgress = _ws.pipelines.find(p = > p.name === " In Progress " );
if (!_inProgress) throw new Error(` ' In Progress ' 파이프라인 없음. 라이브: ${_ws.pipelines.map(p = > p.name).join( " , " )}`);
const inProgressPipelineId = _inProgress.id;
for (const { number: n } of candidates) {
const kids = await openChildrenStatus(n); // GitHub sub-issues, tri-state
if (kids.status === " unknown " ) { results.childLookupFailed.push(n); continue; }
if (kids.status !== " open " ) continue; // 자식 없음/전부 닫힘 → 정상
const list = kids.open.map(c = > " # " + c.number).join( " , " );
await Bash(`gh issue reopen ${n}`);
await Bash(`gh issue comment ${n} --body " ⚠️ 열린 하위 이슈가 남아 있어 재오픈했습니다: ${list} " `);
// reopen 이후에만 파이프라인 이동 (닫힌 이슈를 열린 칸으로 옮기면 GitHub 이 재오픈시킨다)
const zh = (await mcp__zenhub__searchLatestIssues({ query: `#${n}` })).find(i = > i.number === n);
if (zh) await mcp__zenhub__moveIssueToPipeline({ issueId: zh.id, pipelineId: inProgressPipelineId });
results.reopened.push({ number: n, open: kids.open.map(c = > c.number) });
}
// 4. Report — never leave a silent partial result
log(`동기화 완료: ${results.synced.length}건 / 이미 일치: ${results.alreadyOk.length}건 / ZenHub 미추적: ${results.notFoundInZenhub.length}건`);
if (results.reopened.length) {
log(`⛔ 열린 자식이 남아 재오픈: ${results.reopened.map(r = > `#${r.number}(자식 ${r.open.length})`).join( " , " )}`);
log(` → 각 부모는 남은 자식 처리 후 /cc-dev:batch {번호} 로 마무리하십시오`);
}
if (results.childLookupFailed.length) log(`⚠️ 자식 조회 실패(판정 불가): ${results.childLookupFailed.map(n = > " # " + n).join( " , " )}`);--issueand the bulk scan share the same per-issue reconciliation step —--issuejust skips thegh issue listdiscovery call.- 3번은 2번의 반대 방향 점검이다. 2번은 "GitHub 에서 닫혔는데 ZenHub 가 못 따라온" 드리프트를 맞추고, 3번은 "애초에 닫히면 안 됐던" 이슈(열린 자식을 가진 컨테이너)를 찾아 되돌린다. 이미 어긋난 계층을 한 번에 복구하는 경로이므로,
/cc-dev:batch사고 이후 정리에 이걸 쓴다:/cc-dev:zenhub:manage sync-closed --issue {epic}. notFoundInZenhubis not an error: some GitHub issues (docs-only chores, etc.) are intentionally never tracked in ZenHub. Report it, don't fail on it.- This performs the same close+verify steps as §3 (
searchClosedIssues→updateIssue({state:"CLOSED"})), just reachable standalone instead of only as a post-merge step — see zenhub-conventions.md → "Issue Closure Policy" for why the drift happens in the first place (webhook-permission gaps, hierarchical merges whose base isn't the default branch).
5. sweep-stale-claims — 낡은 점유 청소 (크래시 잔류 복구)#
세션이 크래시·강제 종료되면 그 이슈는 In Progress + 점유 상태로 남는다. 착수 가드
(Work Claim Contract)는 그것을 "남이 하고
있다"로 읽으므로, TTL(기본 4h)이 지나기 전까지 아무 에이전트도 그 이슈를 집지 못한다. 이 명령이
그 상태를 사람이 한 번에 정리하는 경로다 — sync-closed 와 같은 성격의 사후 수리 명령이다.
/cc-dev:zenhub:manage sweep-stale-claims # In Progress 전수 점검 후 낡은 점유만 회수
/cc-dev:zenhub:manage sweep-stale-claims --dry-run # 무엇을 되돌릴지만 출력 (변경 없음)
/cc-dev:zenhub:manage sweep-stale-claims --ttl 8h # TTL 조정 (기본 4h — batch --worker-timeout 과 동일)알고리즘:
// 1. In Progress 칸의 이슈를 전수 조회 (이 명령의 대상은 그 칸 하나뿐이다)
const ws = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const inProgress = ws.pipelines.find(p = > p.name === " In Progress " );
if (!inProgress) throw new Error(` ' In Progress ' 파이프라인 없음. 라이브: ${ws.pipelines.map(p= > p.name).join( " , " )}`);
// ⚠️ repositoryIds 는 필수다 — 빼면 호출이 실패한다(워크스페이스 전 저장소를 넘긴다)
const issues = await mcp__zenhub__getIssuesInPipeline({
pipelineId: inProgress.id,
repositoryIds: ws.githubRepositories.map(r = > r.id),
});
// 2. 회수 조건은 **둘 다** 성립할 때만 (하나만으로는 살아 있는 작업을 죽인다)
for (const issue of issues) {
const status = await claimStatus(issue.number, me); // Work Claim Contract
if (status === " other-live " || status === " mine " ) continue; // ⛔ 살아 있는 점유는 건드리지 않는다
if (status === " unknown " ) { log(`⚠️ #${issue.number} 판정 불가 — 건너뜀`); continue; }
// status === " stale " (TTL 초과 + 브랜치/PR 활동 없음) 또는 " none " (대장 없음)
// - " none " 은 cascade 로 올라온 컨테이너일 수 있다 → **자식이 열려 있으면 정상**이므로 제외한다
const kids = await openChildrenStatus(issue.number);
if (kids.status !== " none " ) { log(`↩︎ #${issue.number} 컨테이너(진행 중 자식 있음) — 유지`); continue; }
if (status === " none " & & await hasRecentActivity(issue.number, null, ttl)) continue; // 대장 없는 사람 작업
if (args.dryRun) { log(`(dry-run) #${issue.number} → Sprint Backlog`); continue; }
await reflectBoardState(issue, " aborted " ); // holding 으로 회수
await releaseClaim(issue, " swept:stale-claim " ); // 대장을 released 로 (지우지 않는다)
await Bash(`gh issue comment ${issue.number} --body " 🧹 낡은 점유 회수 — 마지막 활동 이후 ${ttlLabel} 경과. 이어서 하려면 \`/cc-dev:run ${issue.number}\` " `);
}- ⛔ 살아 있는 점유를 지우지 않는다 — TTL 초과와 활동 없음이 둘 다 필요하다. 하나만 보고 회수하면 오래 걸리는 정상 작업(대형 마이그레이션·긴 CI)을 남의 손으로 끊는 셈이 된다.
- 진행 중인 자식을 가진 컨테이너는 제외한다 — 그
In Progress는 cascade 산물이며 정상이다. - 회수는 보드와 대장만 되돌린다. 브랜치·PR·커밋은 손대지 않는다(사람이 이어받을 재료다).
PR Linking Best Practices#
GitHub Auto-Link Keywords#
Include the following keywords in PR body for GitHub auto-linking:
## Related Issue
- Closes #1413
- Fixes #1413
- Resolves #1413ZenHub Linking Limitations#
- PRs cannot be set as children of issues in ZenHub
- Using GitHub's "Closes #" keyword is recommended
- In ZenHub, manage state via pipeline moves
MCP Tool Usage#
Issue Search#
mcp__zenhub__searchLatestIssues({ query: " 1413 " })Pipeline Move#
// Query workspace info (on first call)
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const reviewPipeline = workspace.pipelines.find(p = > p.name === " Review/QA " );
// fail-closed: 못 찾으면 throw (이름 불일치 시 undefined.id 무음 누락 방지)
if (!reviewPipeline) throw new Error(` ' Review/QA ' 파이프라인 없음. 라이브: ${workspace.pipelines.map(p = > p.name).join( " , " )}`);
mcp__zenhub__moveIssueToPipeline({
issueId: " {issue_graphql_id} " ,
pipelineId: reviewPipeline.id
})Repository / Issue Type / Organization Query#
All IDs differ per project workspace. Dynamically query based on the X-zh-workspace header configured in .mcp.json:
// Repository ID + Pipeline ID
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
// Issue Type ID
const issueTypes = await mcp__zenhub__getIssueTypes();Key Rules#
- On PR Creation: Include
Closes #{issue}in body - On Work Start: Move to In Progress
- On Review Request: Move to Review/QA
- After Merge (merge = Close):
Closes #auto-closes GitHub → ZenHub syncs to Closed. Never use Done (Done ≠ closed); verify GitHubclosed+ ZenHubClosedand fall back togh issue close/updateIssue state:CLOSED - Issue Hierarchy: Maintain Initiative > Project > Epic > Feature/Bug/Task > Sub-task hierarchy — Initiative/Project/Epic may be omitted for small-scope requests (see
/cc-dev:zenhub:breakdown's Hierarchy Scale Inference) - Out-of-band closes: Issues closed outside a
/cc-dev:run//cc-dev:zenhub:managesession (manual GitHub close, teammate's direct merge, non-default-branch hierarchical merge) never trigger rule 4 automatically — run/cc-dev:zenhub:manage sync-closedto reconcile them on demand