LogoSkills

zenhub-manage

PR 과 이슈를 연결하고 보드 칸을 옮기며, 머지 후 안 닫힌 이슈를 닫고 자식이 남았는데 닫힌 상위 이슈를 다시 엽니다(`sync-closed`) — 파이프라인 밖에서 머지했을 때의 복구 경로.

/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 — GitHub closed 상태인데 ZenHub가 아직 Closed로 못 잡은 이슈를 찾아 강제로 맞춥니다. 인자 없이 실행하면 최근 7일 내 닫힌 이슈를 전부 훑고, --issue로 하나만 콕 집어 확인할 수도 있습니다. 덧붙여, 하위 작업이 열려 있는데도 닫혀 있는 상위 작업(Epic 등)을 찾아 자동으로 다시 열고 어떤 하위 작업이 남았는지 코멘트로 남깁니다.

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

작업 흐름에 맞춰 보통 다음 순서로 진행됩니다.

  1. 작업 시작 — 이슈를 In Progress 칸으로 옮깁니다.
  2. PR 생성 후 — PR 본문에 Closes #이슈번호를 넣어 GitHub가 자동 연결하게 하고, 이슈를 Review/QA 칸으로 옮깁니다.
  3. 병합 후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#

ActionDescriptionExample
link-prLink PR to issue/cc-dev:zenhub:manage link-pr --pr 1627 --issue 1413
moveMove issue to pipeline/cc-dev:zenhub:manage move --issue 1413 --to "Review/QA"
statusCheck issue/PR status/cc-dev:zenhub:manage status --issue 1413
sync-closedReconcile 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:CLOSED

4. 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 ' s

Algorithm:

// 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( " ,  " )}`);
  • --issue and the bulk scan share the same per-issue reconciliation step — --issue just skips the gh issue list discovery call.
  • 3번은 2번의 반대 방향 점검이다. 2번은 "GitHub 에서 닫혔는데 ZenHub 가 못 따라온" 드리프트를 맞추고, 3번은 "애초에 닫히면 안 됐던" 이슈(열린 자식을 가진 컨테이너)를 찾아 되돌린다. 이미 어긋난 계층을 한 번에 복구하는 경로이므로, /cc-dev:batch 사고 이후 정리에 이걸 쓴다: /cc-dev:zenhub:manage sync-closed --issue {epic}.
  • notFoundInZenhub is 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 (searchClosedIssuesupdateIssue({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#

Include the following keywords in PR body for GitHub auto-linking:

## Related Issue
- Closes #1413
- Fixes #1413
- Resolves #1413

ZenHub 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#

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#

  1. On PR Creation: Include Closes #{issue} in body
  2. On Work Start: Move to In Progress
  3. On Review Request: Move to Review/QA
  4. After Merge (merge = Close): Closes # auto-closes GitHub → ZenHub syncs to Closed. Never use Done (Done ≠ closed); verify GitHub closed + ZenHub Closed and fall back to gh issue close / updateIssue state:CLOSED
  5. 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)
  6. Out-of-band closes: Issues closed outside a /cc-dev:run//cc-dev:zenhub:manage session (manual GitHub close, teammate's direct merge, non-default-branch hierarchical merge) never trigger rule 4 automatically — run /cc-dev:zenhub:manage sync-closed to reconcile them on demand