LogoSkills

ZenHub Conventions

Issue management rules using ZenHub.

Issue management rules using ZenHub.

Per-Project Configuration#

ZenHub workspace information is managed in .mcp.json at the project root. Pipeline ID, Repository ID, Issue Type ID, and Organization ID are not hardcoded but dynamically queried via MCP tools.

Workspace Info Query (required on first call)#

// 1. Pipeline ID + Repository ID query
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
// โ†’ pipelines[]: { id, name }  (New Issues, Icebox, Product Backlog, ...)
// โ†’ githubRepositories[]: { id, name }
// โ†’ zenhubOrganization: { id, name }
const repositoryId = workspace.githubRepositories.find(r = >   /* select GitHub repo */).id;

// 2. Issue Type ID query
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId });
// โ†’ issueTypes[]: { id, name, level }  (Initiative, Project, Epic, Feature, Bug, Task, Sub-task, ...)

Rule: Always query the current workspace IDs via the above two calls before creating/moving ZenHub issues. IDs can be cached and reused within the same session.


Issue Type Hierarchy#

Issue types are hierarchical (level field, lower = higher level). Standard workspace hierarchy:

LevelTypeScopeParent
1InitiativeMulti-quarter strategic themeโ€”
2 Project Product/milestone unit (multiple features) Initiative (optional)
3EpicSingle feature unitProject (optional)
4Feature / Bug / TaskStory (screen/work unit)Epic
5Sub-taskDetailed taskStory

Hierarchy rules:

  • Parent-child links use parentIssueId (at creation) or setParentForIssues (after creation)
  • A child's type level must be greater than its parent's level (e.g., Epic(3) under Project(2) โœ…, Epic under Epic โŒ)
  • Project/Initiative types may not exist in every workspace โ€” always check via getIssueTypes() first; if absent, fall back to Epic as the top level and warn the user
const projectType = issueTypes.find(t = >   t.name ===  " Project " );
if (!projectType) {
  // Fallback: skip Project level, create Epic(s) without parent + warn
}

Issue Creation Rules#

Use GitHub Issues (required)#

All issues must be created as GitHub issues (ZenHub issues are prohibited)

// โœ… CORRECT: Create GitHub issue
mcp__zenhub__createGitHubIssue({
  title:  " Feature development " ,
  repositoryId: repositoryId, // From getWorkspacePipelinesAndRepositories()
  issueTypeId: epicTypeId,    // From getIssueTypes()
})

// โŒ WRONG: Create ZenHub issue (prohibited)
mcp__zenhub__createZenhubIssue({...})

Reasons:

  • GitHub issues support pipeline moves
  • Timeline settings work correctly
  • Auto-link with GitHub PRs
  • Easy search and filtering

ID Query Patterns#

Finding Pipeline ID#

const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const pipelines = workspace.pipelines;

// Find by name
const inProgress = pipelines.find(p = >   p.name ===  " In Progress " );
const reviewQA = pipelines.find(p = >   p.name ===  " Review/QA " );
// Note: do NOT route completed issues to  " Done "   โ€” Done โ‰  closed (see Issue Closure Policy).

Finding Issue Type ID#

const types = await mcp__zenhub__getIssueTypes({ repositoryId });

// Find by name (each type has { id, name, level })
const projectType = types.find(t = >   t.name ===  " Project " );
const epicType = types.find(t = >   t.name ===  " Epic " );
const featureType = types.find(t = >   t.name ===  " Feature " );
const bugType = types.find(t = >   t.name ===  " Bug " );
const subtaskType = types.find(t = >   t.name ===  " Sub-task " );

Finding Repository ID#

const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repos = workspace.githubRepositories;

// Find GitHub repo (by name)
const githubRepo = repos.find(r = >   r.name ===  " my-repo " );

Issue Creation Examples#

Project Creation (top-level)#

const projectTypeId = issueTypes.find(t = >   t.name ===  " Project " ).id;

// Create Project (GitHub issue) โ€” wraps multiple Epics
const project = await mcp__zenhub__createGitHubIssue({
  title:  " Service name/milestone name " ,
  body:  " Project description, goals, Epic list... " ,
  repositoryId: repoId,
  issueTypeId: projectTypeId,
  labels: [ " project " ,  " p0 " ], // Priority label decided BEFORE creation
});

// Link Epics created later as children
await mcp__zenhub__setParentForIssues({
  parentIssueId: project.id,
  childIssueIds: [epic1.id, epic2.id],
});

Epic Creation#

// 1. Dynamic ID query
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoId = workspace.githubRepositories[0].id; // Or find by name
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId: repoId });

const epicTypeId = issueTypes.find(t = >   t.name ===  " Epic " ).id;
const orgId = workspace.zenhubOrganization.id; // Organization ID

// 2. Create Epic (GitHub issue)
const epic = await mcp__zenhub__createGitHubIssue({
  title:  " Feature name " ,
  body:  " Epic description... " ,
  repositoryId: repoId,
  issueTypeId: epicTypeId,
});

// 3. Set timeline
await mcp__zenhub__setDatesForIssue({
  issueId: epic.id,
  startDate:  " 2026-01-27 " ,
  endDate:  " 2026-02-17 " ,
  zenhubOrganizationId: orgId,
});

// 4. Move pipeline
const backlogPipeline = workspace.pipelines.find(p = >   p.name ===  " Product Backlog " );
await mcp__zenhub__moveIssueToPipeline({
  issueId: epic.id,
  pipelineId: backlogPipeline.id,
});

Story Creation#

const featureTypeId = issueTypes.find(t = >   t.name ===  " Feature " ).id;

// Create Story (under Epic)
const story = await mcp__zenhub__createGitHubIssue({
  title:  " Feature name " ,
  body:  " Story description... " ,
  repositoryId: repoId,
  issueTypeId: featureTypeId,
  parentIssueId: epic.id, // Link to Epic
});

// Set Story Points
await mcp__zenhub__setIssueEstimate({
  issueId: story.id,
  estimate: 5,
});

Priority Review and Pipeline Sorting (required after creation)#

After creating Project/Epic/Story issues, always run a priority review and place each issue in the appropriate pipeline.

Priority Scoring Criteria#

Score each issue on 4 axes and assign a priority tier:

AxisQuestionWeight
Dependency (blocker) Do other Stories depend on this? (e.g., Entity/API foundation) High
Business valueIs it on the Epic's core user path?High
Risk/uncertaintyTechnically uncertain โ†’ tackle early to reduce riskMedium
Effort (points)Among equals, smaller points first (fast feedback)Low
TierLabelCriteria
P0p0Blocker or core-path Story โ€” must be done first
P1p1Core feature in scope, no dependents
P2p2Improvement/nice-to-have, deferrable

โš ๏ธ Labels must be set at creation time โ€” updateIssue does not support label changes. Therefore, the priority review happens before issue creation; labels are passed to createGitHubIssue.

Priority โ†’ Pipeline Placement#

TargetPipeline
Project / EpicProduct Backlog
Story P0 (with --sprint) Sprint Backlog + addIssuesToSprints
Story P0/P1 (no sprint)Product Backlog
Story P2Icebox
Sub-taskFollows parent Story (no separate move)

Within-Pipeline Ordering#

moveIssueToPipeline does not support a position parameter, so exact in-pipeline ordering cannot be set via MCP. Approximate it with:

  1. Move in descending priority order โ€” move P0 issues first, then P1, then P2 (one moveIssueToPipeline call each, sequentially)
  2. Record the priority table in the parent issue body โ€” Epic/Project body includes a ## ๐Ÿ”ข ์šฐ์„ ์ˆœ์œ„ table (rank / issue # / tier / rationale) as the source of truth
  3. Fine-grained drag ordering, if needed, is adjusted manually on the ZenHub board (guide the user)

Sprint Selector Resolution#

--sprint ๊ฐ’์„ ์Šคํ”„๋ฆฐํŠธ๋กœ ํ•ด์„ํ•˜๋Š” ํ‘œ์ค€ ๊ทœ์น™ โ€” ๋ชจ๋“  ์ปค๋งจ๋“œ(/dev:run, /zenhub:epic, โ€ฆ)๊ฐ€ ๋™์ผํ•˜๊ฒŒ ๋”ฐ๋ฅธ๋‹ค:

์…€๋ ‰ํ„ฐํ•ด์„MCP
current (๊ธฐ๋ณธ) ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ getSprint() (id ์—†์ด ํ˜ธ์ถœ = ํ™œ์„ฑ)
next๋‹ค์Œ ์Šคํ”„๋ฆฐํŠธgetUpcomingSprint()
์ˆซ์ž / ์ด๋ฆ„ ์ผ๋ถ€ ๋งค์นญ๋˜๋Š” ์—ด๋ฆฐ ์Šคํ”„๋ฆฐํŠธ listRecentSprints().openSprints.find(s => s.name.includes(sel))

โš ๏ธ getUpcomingSprint() ๋Š” '๋‹ค์Œ' ์Šคํ”„๋ฆฐํŠธ๋‹ค โ€” 'ํ™œ์„ฑ'์ด ์•„๋‹ˆ๋‹ค. current ๋ฅผ getUpcomingSprint ๋กœ ํ•ด์„ํ•˜๋ฉด ์ด์Šˆ๊ฐ€ ํ•œ ์Šคํ”„๋ฆฐํŠธ ๋’ค๋กœ ๋ฐ€๋ ค ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ/๋ฒˆ๋‹ค์šด์—์„œ ๋น„๊ฒŒ ๋œ๋‹ค. current ๋Š” ๋ฐ˜๋“œ์‹œ getSprint() ๋ฅผ ์“ด๋‹ค.

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)));
}

Roadmap Visibility Contract (๋ชจ๋“  ์ด์Šˆ๋ฅผ ํƒ€์ž„๋ผ์ธ์— ๋…ธ์ถœ)#

๋กœ๋“œ๋งต ํƒ€์ž„๋ผ์ธ์€ setDatesForIssue ์˜ start/end ๋กœ ๋ Œ๋”๋˜๋ฉฐ, setDatesForIssue ๋Š” ์ƒ์œ„ ํƒ€์ž…(Epic/Project/Initiative) ์ „์šฉ์ด๋‹ค(MCP ์Šคํ‚ค๋งˆ ์ œ์•ฝ). ๋”ฐ๋ผ์„œ 2-๋ ˆ์ธ ๊ทœ์น™์œผ๋กœ ๋ชจ๋“  ์ถ”์  ์ด์Šˆ๊ฐ€ ๋ณด์ด๊ฒŒ ํ•œ๋‹ค:

์ด์Šˆ ๋ ˆ๋ฒจํƒ€์ž„๋ผ์ธ ๋…ธ์ถœ ๋ฐฉ๋ฒ•MCP
Project / Epic / Initiative ๋ช…์‹œ์  ๊ธฐ๊ฐ„(start/end) setDatesForIssue (+ zenhubOrganizationId)
Story / Feature / Bug / Task ํ™œ์„ฑ ์Šคํ”„๋ฆฐํŠธ ๋ฉค๋ฒ„์‹ญ (๋‚ ์งœ ์„ค์ • โŒ) addIssuesToSprints(resolveSprint("current"))

์ €์ˆ˜์ค€ ํƒ€์ž…์— setDatesForIssue ๋ฅผ ์“ฐ์ง€ ๋ง ๊ฒƒ โ€” ์Šคํ‚ค๋งˆ์ƒ off-spec ์ด๋ฉฐ ๋กœ๋“œ๋งต ๋ Œ๋”๊ฐ€ ๋น„์ •์ƒ์ผ ์ˆ˜ ์žˆ๋‹ค. ์Šคํ”„๋ฆฐํŠธ ๋ ˆ์ธ์œผ๋กœ ๋…ธ์ถœํ•œ๋‹ค.

Matrix Signals (impact ร— effort)#

ZenHub Matrix ๋Š” impact(๊ฐ€์น˜) ร— effort(๋…ธ๋ ฅ) 2์ถ•์œผ๋กœ ์ด์Šˆ๋ฅผ plot ํ•œ๋‹ค. ๋‘ ์ถ•์„ ๋ชจ๋‘ ์˜์†ํ™”ํ•ด์•ผ ๋งคํŠธ๋ฆญ์Šค๊ฐ€ ์ฑ„์›Œ์ง„๋‹ค:

์ถ•์‹ ํ˜ธ์ €์žฅ ๋ฐฉ๋ฒ•
effort (X)Story PointsetIssueEstimate
impact (Y) Business value + Dependency + Risk (effort ์ œ์™ธ) ๋ถˆ๋ณ€ ๋ผ๋ฒจ impact-high / impact-med / impact-low

โš ๏ธ ๋ผ๋ฒจ์€ ์ƒ์„ฑ ํ›„ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€(updateIssue ๋ฏธ์ง€์›) โ†’ ์ƒ์„ฑ ์‹œ์ ์— createGitHubIssue ์˜ labels ๋กœ ํ™•์ •ํ•œ๋‹ค. impact ์— effort ๋ฅผ ์„ž์œผ๋ฉด ๋งคํŠธ๋ฆญ์Šค์—์„œ ์ด์ค‘ ๊ณ„์‚ฐ๋˜๋ฏ€๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. P0/P1/P2 ํ‹ฐ์–ด๋Š” ์ •๋ ฌ์šฉ์ด๊ณ , impact ๋ผ๋ฒจ์€ ๋งคํŠธ๋ฆญ์Šค ์ถ•์šฉ์œผ๋กœ ๋ณ„๊ฐœ๋‹ค.

Blocked Issue Contract#

์ž‘์—…์ด ์ฐจ๋‹จ๋˜๋ฉด ๋ณด๋“œ์— ๋ฐ˜์˜ํ•ด ํ™œ์„ฑ ์ž‘์—…๊ณผ ๊ตฌ๋ถ„ํ•œ๋‹ค(์ฐจ๋‹จ๋œ ์ด์Šˆ๋ฅผ In Progress ์— ๋ฐฉ์น˜ ๊ธˆ์ง€):

์‹œ์ ๋™์ž‘MCP
์ฐจ๋‹จ ๋ฐœ์ƒ (์˜ˆ: stall ladder Rung 3 BLOCKED) ์ฐจ๋‹จ ์˜์กด ๊ธฐ๋ก + holding ์ปฌ๋Ÿผ ์ด๋™ createBlockage({blockedIssueId, blockingIssueId}) + moveIssueToPipeline("Sprint Backlog")
์ฐจ๋‹จ ํ•ด์ œ holding โ†’ In Progress ๋ณต๊ท€ ํ›„ ์ž‘์—… ์žฌ๊ฐœ moveIssueToPipeline("In Progress")

createBlockage ๋Š” ์ฐจ๋‹จ ์˜์กด ๊ด€๊ณ„๋ฅผ ๊ธฐ๋กํ•˜๊ณ , ํŒŒ์ดํ”„๋ผ์ธ ์ด๋™์€ ์ฐจ๋‹จ ์ƒํƒœ๋ฅผ ๋ณด๋“œ์— ๊ฐ€์‹œํ™”ํ•œ๋‹ค โ€” ๋‘˜์€ ๋ณด์™„ ๊ด€๊ณ„๋‹ค. ์ฐจ๋‹จ ์‚ฌ์œ ๋Š” ์ด์Šˆ ์ฝ”๋ฉ˜ํŠธ๋กœ ๋‚จ๊ธด๋‹ค.

// Example: priority-ordered placement after creation
const backlog = workspace.pipelines.find(p = >   p.name ===  " Product Backlog " );
// fail-closed: ๋ชป ์ฐพ์œผ๋ฉด throw (์ด๋ฆ„ ๋ถˆ์ผ์น˜ ์‹œ ๋ถ€๋ถ„ ๋ฐฐ์น˜ ํ›„ ๋ฌด์Œ ์ค‘๋‹จ ๋ฐฉ์ง€)
if (!backlog) throw new Error(` ' Product Backlog '   ํŒŒ์ดํ”„๋ผ์ธ ์—†์Œ. ๋ผ์ด๋ธŒ: ${workspace.pipelines.map(p = >   p.name).join( " ,  " )}`);
const sorted = stories.sort((a, b) = >   a.priorityRank - b.priorityRank); // P0 โ†’ P1 โ†’ P2
for (const s of sorted) {
  await mcp__zenhub__moveIssueToPipeline({ issueId: s.id, pipelineId: backlog.id });
}

Issue Title Conventions#

TypePrefixExample
Project(์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„)Admin console v2 milestone
Epic (์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„) API integration and SWR caching strategy
Story(์—†์Œ โ€” Issue Type์œผ๋กœ ๊ตฌ๋ถ„)classroom API integration
Bugfix:fix: Login token refresh error
Featurefeat:feat: Add user profile page
Taskchore:chore: Dependency update

Note: Epic/Story๋Š” ZenHub Issue Type์œผ๋กœ ์ด๋ฏธ ๊ตฌ๋ถ„๋˜๋ฏ€๋กœ ์ œ๋ชฉ์— [Epic], [Story] ์ ‘๋‘์‚ฌ๋ฅผ ๋ถ™์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.


Issue Closure Policy ("merge = Close") โš ๏ธ#

ZenHub tracks two independent states โ€” confusing them leaves issues stuck open:

StateOwnerMeaning
Pipeline ZenHub Board column (In Progress, Review/QA, Done, โ€ฆ). Just a position.
GitHub state GitHub The real open / closed flag.
  • Done pipeline โ‰  closed. An issue moved to Done is still open on GitHub. Done means "ready to close", not closed.
  • Closed pipeline = GitHub closed, 1:1. Judge open/closed by GitHub state, never by a column.
  • An issue truly closes via exactly one of: (1) drag to Closed pipeline, (2) close on GitHub, (3) a PR merging with Closes #N โ€” but (3) fires ONLY when the PR merges into the repository's default branch (a hard GitHub rule).

โš ๏ธ ์ด ์›Œํฌ์ŠคํŽ˜์ด์Šค์—๋Š” Closed ์ปฌ๋Ÿผ์ด ์—†๋‹ค (๋ผ์ด๋ธŒ 6์ข…: New Issues ยท Icebox ยท Product Backlog ยท Sprint Backlog ยท In Progress ยท Review/QA). ๋”ฐ๋ผ์„œ ์ •์‹ close ๋ฉ”์ปค๋‹ˆ์ฆ˜์€ gh issue close --reason completed + updateIssue({state:"CLOSED"}) ์ด๋ฉฐ, ์•„๋ž˜์˜ "Closed ํŒŒ์ดํ”„๋ผ์ธ์œผ๋กœ ์ด๋™" ๊ฒฝ๋กœ๋Š” Closed ์ปฌ๋Ÿผ์„ ๋…ธ์ถœํ•˜๋Š” ์›Œํฌ์ŠคํŽ˜์ด์Šค์—์„œ๋งŒ ์ ์šฉ๋œ๋‹ค(find(p=>p.name==="Closed") ๊ฐ€ undefined ๋ฉด ๊ทธ ๊ฒฝ๋กœ๋Š” not-applicable โ€” ๋ฌด์Œ skip ์„ close ์„ฑ๊ณต์œผ๋กœ ์˜ค์ธ ๊ธˆ์ง€).

๐Ÿšซ Never moveIssueToPipeline a closed issue into a non-Closed pipeline (Product Backlog, In Progress, Review/QA, Done, โ€ฆ). Those board columns render open issues only, so dropping a closed issue into one reopens it on GitHub to materialize the card. This is the move to avoid.

โœ… The Closed pipeline is the exception โ€” routing there never reopens. It is wired 1:1 to GitHub closed, so the move is safe in both directions: for an already-closed issue it is a no-op (it is already in Closed; no reopen), and for an open issue it actively closes it on GitHub (valid close mechanism #1, equivalent to gh issue close). So moveIssueToPipeline({ pipelineId: <Closed>.id }) is an acceptable explicit-close / fallback โ€” resolve the id with getWorkspacePipelinesAndRepositories().pipelines.find(p => p.name === "Closed") (skip gracefully if the workspace does not expose it). gh issue close remains the simplest path; this is a sanctioned alternative, not a prohibition.

Once an issue is closed on GitHub, ZenHub auto-syncs it to Closed via webhook with no pipeline move needed. If you accidentally moved a closed issue into a non-Closed column and it reopened, re-close it on GitHub (gh issue close) or move it to the Closed pipeline โ€” do not leave it parked open in the wrong column.

โœ… GitHub close โ†’ ZenHub Closed is automatic (webhook), zero extra setup. ZenHub auto-provisions the repo webhook and also periodically rescans, so a GitHub close propagates to the board on its own. If it is NOT syncing, the cause is almost always a webhook-permission gap: a repo admin must have logged into ZenHub at least once (ZenHub manages the webhook under that admin's token). Prefer verifying the repo's sync/connection state in workspace settings; if you do need to force the state, move the issue to the Closed pipeline (safe โ€” see above), never to a non-Closed column.

โš ๏ธ Hierarchical (work-base) merges never auto-close. In the branch hierarchy (task/ โ†’ story/ โ†’ epic/ โ†’ development), Sub-task and Story PRs merge into a parent work-base branch, not the default branch โ€” so GitHub's Closes #N never fires, the issue stays open, and ZenHub never reaches Closed. Even epic/ โ†’ development does not auto-close unless development is the repo's default branch. For any merge whose base โ‰  default branch, the explicit close (step 3 below) is the PRIMARY mechanism, not a fallback. See branch-hierarchy.

This repo's policy = (B) "merge = Close". AI agents run full-stack E2E tests + review before merge, so a merged PR counts as Done and Closed. Therefore:

  1. Every PR body includes Closes #{number}. Squash merge auto-closes GitHub โ†’ ZenHub syncs to Closed only if the base is the default branch. If the base is a parent work-base branch (hierarchical merge), it will NOT auto-close โ€” you must close explicitly (step 3).
  2. Never park completed issues in Done (it would leave them open).
  3. After every merge, close + verify โ€” unconditionally. This is mandatory (not optional) for non-default-base merges, and also covers silent miss / sync lag on default-branch merges:
// 0. `Closes #N` auto-closes ONLY on default-branch merges. For hierarchical merges
//    (base = story/ or epic/) it never fires, so step 1 IS the close, not a safety net.
const defaultBranch = (await Bash(`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`)).trim();

// 1. GitHub is the source of truth โ€” close explicitly whenever still open
const state = await Bash(`gh issue view ${n} --json state -q .state`);
if (state.trim() !==  " CLOSED " ) await Bash(`gh issue close ${n} --reason completed`);

// 2. Confirm ZenHub synced to Closed; force if lagging
const closed = await mcp__zenhub__searchClosedIssues({ query: `#${n}` });
if (!closed.find(i = >   i.number === n)) {
  await mcp__zenhub__updateIssue({ issueId, state:  " CLOSED "   });
}

Reporting note: ZenHub reports/burndown count Closed as Done by default. Issues left only in a Done column are not counted as complete โ€” another reason to always reach Closed.


Notes#

  1. ZenHub Issues vs GitHub Issues

    • ZenHub issues: Cannot move pipelines/set timelines
    • GitHub issues: All ZenHub features work correctly
  2. Parent-Child Relationships

    • Link on creation via parentIssueId parameter
    • Or link later with setParentForIssues
  3. Search

    • searchLatestIssues: Only searches GitHub issues
    • ZenHub issues are not searchable