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:
| Level | Type | Scope | Parent |
|---|---|---|---|
| 1 | Initiative | Multi-quarter strategic theme | โ |
| 2 | Project | Product/milestone unit (multiple features) | Initiative (optional) |
| 3 | Epic | Single feature unit | Project (optional) |
| 4 | Feature / Bug / Task | Story (screen/work unit) | Epic |
| 5 | Sub-task | Detailed task | Story |
Hierarchy rules:
-
Parent-child links use
parentIssueId(at creation) orsetParentForIssues(after creation) -
A child's type
levelmust 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:
| Axis | Question | Weight |
|---|---|---|
| Dependency (blocker) | Do other Stories depend on this? (e.g., Entity/API foundation) | High |
| Business value | Is it on the Epic's core user path? | High |
| Risk/uncertainty | Technically uncertain โ tackle early to reduce risk | Medium |
| Effort (points) | Among equals, smaller points first (fast feedback) | Low |
| Tier | Label | Criteria |
|---|---|---|
| P0 | p0 | Blocker or core-path Story โ must be done first |
| P1 | p1 | Core feature in scope, no dependents |
| P2 | p2 | Improvement/nice-to-have, deferrable |
โ ๏ธ Labels must be set at creation time โ
updateIssuedoes not support label changes. Therefore, the priority review happens before issue creation; labels are passed tocreateGitHubIssue.
Priority โ Pipeline Placement#
| Target | Pipeline |
|---|---|
| Project / Epic | Product Backlog |
Story P0 (with --sprint) |
Sprint Backlog + addIssuesToSprints |
| Story P0/P1 (no sprint) | Product Backlog |
| Story P2 | Icebox |
| Sub-task | Follows 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:
-
Move in descending priority order โ move P0 issues first, then P1, then P2 (one
moveIssueToPipelinecall each, sequentially) -
Record the priority table in the parent issue body โ Epic/Project body includes a
## ๐ข ์ฐ์ ์์table (rank / issue # / tier / rationale) as the source of truth - 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 Point | setIssueEstimate |
| 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#
| Type | Prefix | Example |
|---|---|---|
| Project | (์์ โ Issue Type์ผ๋ก ๊ตฌ๋ถ) | Admin console v2 milestone |
| Epic | (์์ โ Issue Type์ผ๋ก ๊ตฌ๋ถ) | API integration and SWR caching strategy |
| Story | (์์ โ Issue Type์ผ๋ก ๊ตฌ๋ถ) | classroom API integration |
| Bug | fix: | fix: Login token refresh error |
| Feature | feat: | feat: Add user profile page |
| Task | chore: | 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:
| State | Owner | Meaning |
|---|---|---|
| Pipeline | ZenHub | Board column (In Progress, Review/QA, Done, โฆ). Just a position. |
| GitHub state | GitHub | The real open / closed flag. |
-
Donepipeline โ closed. An issue moved toDoneis stillopenon GitHub.Donemeans "ready to close", not closed. -
Closedpipeline = GitHubclosed, 1:1. Judge open/closed by GitHub state, never by a column. -
An issue truly closes via exactly one of: (1) drag to
Closedpipeline, (2) close on GitHub, (3) a PR merging withCloses #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
moveIssueToPipelinea closed issue into a non-Closedpipeline (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
Closedpipeline is the exception โ routing there never reopens. It is wired 1:1 to GitHubclosed, so the move is safe in both directions: for an already-closed issue it is a no-op (it is already inClosed; no reopen), and for an open issue it actively closes it on GitHub (valid close mechanism #1, equivalent togh issue close). SomoveIssueToPipeline({ pipelineId: <Closed>.id })is an acceptable explicit-close / fallback โ resolve the id withgetWorkspacePipelinesAndRepositories().pipelines.find(p => p.name === "Closed")(skip gracefully if the workspace does not expose it).gh issue closeremains the simplest path; this is a sanctioned alternative, not a prohibition.Once an issue is
closedon GitHub, ZenHub auto-syncs it toClosedvia webhook with no pipeline move needed. If you accidentally moved a closed issue into a non-Closedcolumn and it reopened, re-close it on GitHub (gh issue close) or move it to theClosedpipeline โ do not leave it parked open in the wrong column.
โ GitHub close โ ZenHub
Closedis 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 theClosedpipeline (safe โ see above), never to a non-Closedcolumn.
โ ๏ธ 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'sCloses #Nnever fires, the issue staysopen, and ZenHub never reachesClosed. Evenepic/โdevelopmentdoes not auto-close unlessdevelopmentis 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:
-
Every PR body includes
Closes #{number}. Squash merge auto-closes GitHub โ ZenHub syncs toClosedonly 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). -
Never park completed issues in
Done(it would leave themopen). - 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
Donecolumn are not counted as complete โ another reason to always reachClosed.
Notes#
-
ZenHub Issues vs GitHub Issues
- ZenHub issues: Cannot move pipelines/set timelines
- GitHub issues: All ZenHub features work correctly
-
Parent-Child Relationships
- Link on creation via
parentIssueIdparameter - Or link later with
setParentForIssues
- Link on creation via
-
Search
searchLatestIssues: Only searches GitHub issues- ZenHub issues are not searchable