/cc-dev:zenhub:triage — 외부 피드백 → 정제된 이슈 만들기#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-dev:zenhub:triage |
| 분류 | 워크플로우 |
| 난이도 | ●●○ 보통 |
| MCP 서버 | zenhub |
한마디로#
지원 문의·Slack 제보·앱스토어 리뷰·버그 신고처럼 여기저기서 들어온 의견을, 보드에 바로 올릴 수 있게 깔끔히 정리한 "접수 카드"로 만들어 주는 도구입니다. 민원 창구 직원이 들어온 민원을 "이거 전에 들어온 그 건이네요" 하고 같은 건끼리 묶고, 처음 보는 건만 새 접수증으로 올려 주는 일을 대신해 준다고 보면 됩니다.
누가·언제 쓰나요#
- 고객 지원/Slack/앱스토어 리뷰에서 받은 의견을 이슈로 남겨야 할 때
- 같은 신고가 여러 번 들어와 중복 이슈가 쌓이는 걸 막고 싶을 때
- 어느 채널에서 들어온 건인지(
source:) 추적하면서 모아 두고 싶을 때
무엇을 해주나요#
- 중복 먼저 확인 — 이미 같은 내용의 이슈(열린 것·닫힌 것 모두)가 있으면 새로 만들지 않고 그쪽에 코멘트로 합칩니다
- 분류 — 버그/요청/작업 등 이슈 종류를 내용으로 알아서 골라 줍니다
- 정제된 이슈 생성 — 채널(
source:)·영향도(impact-) 라벨까지 붙여 접수 카드로 만듭니다 -
접수함에 대기 — 만든 이슈를
New Issues(접수/분류 대기 칸)에 올려 둡니다. 곧장 백로그로 보내지 않습니다 — 사람이 한 번 보고 분류하게요
어떻게 쓰나요#
# Slack에서 들어온 버그 제보를 이슈로
/cc-dev:zenhub:triage --source slack --type Bug --impact high " 결제 후 영수증 화면이 안 떠요 "
# 앱스토어 리뷰를 접수 (종류는 내용으로 추론)
/cc-dev:zenhub:triage --source store --impact med " 다크모드에서 글씨가 안 보임 "
# 실제로 만들지 않고 중복 검사 + 미리보기만
/cc-dev:zenhub:triage --source support --dry-run " 로그인 토큰 만료 오류 "
-
--source <채널>— 어디서 들어온 건지 (slack/support/review/store등).source:<채널>라벨로 영구 기록됩니다 --type <종류>— 이슈 종류 직접 지정 (생략하면 내용으로 추론)--impact high|med|low— 영향도. 생성 시impact-<level>라벨로 확정 (나중에 변경 불가)--dry-run— 중복 검사와 미리보기만 하고 실제 생성/이동은 하지 않음
안에서 무슨 일이 벌어지나요#
- 중복 검사 — 열린 이슈와 닫힌 이슈를 모두 뒤져 "같은 의미"의 기존 이슈가 있는지 봅니다. 단순 단어 겹침이 아니라 내용 의미로 판단합니다.
-
이미 있으면 — 새로 만들지 않고 그 이슈에 코멘트(또는 +1)를 남기고,
source:<채널>라벨 추가를 제안한 뒤 종료합니다. -
처음 보는 건이면 — 이슈 종류를 정하고, 라벨(
needs-triage,source:,impact-)을 붙여 새 이슈를 만듭니다. - 접수함 대기 — 만든 이슈를
New Issues칸에 올려 사람이 분류할 수 있게 둡니다.
참고: 라벨은 한 번 만들면 바꿀 수 없으므로 채널·영향도 라벨은 생성 시점에 확정합니다. 그리고 분류 전이므로 절대
Product Backlog로 바로 보내지 않습니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Triggers#
- External feedback (support ticket, Slack thread, app-store/store review, bug report) needs to become a tracked issue
- Duplicate inbound reports are piling up and need semantic dedup before creation
- Per-channel triage intake with persistent
source:attribution
Context Trigger Pattern#
/cc-dev:zenhub:triage [--source < channel > ] [--type < issueType > ] [--impact high|med|low] [--dry-run] " < feedback text > "Options / Actions#
| Option | Description | Example |
|---|---|---|
--source <channel> | Origin channel; persisted as source:<channel> label | --source slack |
--type <issueType> | Force ZenHub issue type (else inferred from content) | --type Bug |
--impact high|med|low | Matrix impact axis; persisted as impact-<level> label at creation | --impact high |
--dry-run | Run dedup + preview only; no create / no move | --dry-run |
Execution Flow#
1. DEDUP (semantic, LLM — not token overlap)#
// Search BOTH open and closed (a fix may already have shipped & closed).
const open = await mcp__zenhub__searchLatestIssues({ query: feedbackText });
const closed = await mcp__zenhub__searchClosedIssues({ query: feedbackText });
// LLM-judge title+body MEANING against the inbound feedback — do NOT match on
// shared keywords. A " receipt screen blank after payment " report duplicates an
// existing " post-checkout receipt not rendering " issue even with zero word overlap.
const match = semanticDuplicate(feedbackText, [...open, ...closed]); // returns issue | null
if (match) {
// Do NOT create. Comment / +1 on the existing issue and propose the source label.
await mcp__zenhub__updateIssue({ issueId: match.id, body: appendDedupNote(match.body, source) });
// Labels are IMMUTABLE post-creation → cannot add source: < channel > via updateIssue.
// So: report the match to the user and SUGGEST adding `source: < channel > ` manually.
return report(`중복 → #${match.number} 에 합침. 라벨 ' source:${source} ' 수동 추가 제안 (라벨 불변).`);
}2. CLASSIFY (resolve issue type)#
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoId = workspace.githubRepositories[0].id; // or find by name
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId: repoId });
// --type wins; else infer from content (bug symptom → " Bug " , request → " Feature " , chore → " Task " ).
const typeName = options.type ?? inferType(feedbackText);
const issueType = issueTypes.find(t = > t.name === typeName);
if (!issueType) throw new Error(`이슈 타입 ' ${typeName} ' 없음. 라이브: ${issueTypes.map(t = > t.name).join( " , " )}`);3. CREATE (labels locked at creation — immutable afterward)#
본문 계약:
body는 재현 조건·기대/실제 동작 등 파이프라인이 읽는 계약 블록만 담는다. 원문 인용·스크린샷·로그 덤프처럼 긴 서술은 아티팩트로 발행해## 📄 상세 기획링크 블록으로 대체한다 (서술 20행 초과 시에만,Artifact부재 시 전량 마크다운 폴백). 규약:rules/zenhub-conventions.md→ Issue Body Artifact Contract.
const typeLabel = typeName.toLowerCase(); // bug / feature / task
const impactLabel = `impact-${options.impact ?? " med " }`; // Matrix impact axis (see Matrix Signals)
if (options.dryRun) return preview({ title, typeName, labels });
const issue = await mcp__zenhub__createGitHubIssue({
title,
body,
repositoryId: repoId,
issueTypeId: issueType.id,
// ⚠️ labels are immutable post-creation (updateIssue can ' t change them) → set ALL here.
labels: [ " needs-triage " , `source:${options.source}`, impactLabel, typeLabel],
});4. STAGE (New Issues — triage holding column, NOT Product Backlog)#
const newIssues = workspace.pipelines.find(p = > p.name === " New Issues " );
// fail-closed: never leave an unguarded .id
if (!newIssues) throw new Error(` ' New Issues ' 파이프라인 없음. 라이브: ${workspace.pipelines.map(p = > p.name).join( " , " )}`);
await mcp__zenhub__moveIssueToPipeline({ issueId: issue.id, pipelineId: newIssues.id });
// Do NOT route to Product Backlog — triage is a human gate; staging waits in New Issues.MCP Tool Usage#
// Workspace / repo / type IDs (per zenhub-conventions " Workspace Info Query " )
const workspace = await mcp__zenhub__getWorkspacePipelinesAndRepositories();
const repoIds = workspace.githubRepositories.map(r = > r.id);
const issueTypes = await mcp__zenhub__getIssueTypes({ repositoryId: repoIds[0] });
// Dedup corpus (no getIssue/getChildrenOfParent exist — search only)
await mcp__zenhub__searchLatestIssues({ query: " #1413 " }); // open; also " parent: < graphqlId > "
// ⚠️ GD-04: " parent: < graphqlId > " only scans the " latest 20 issues " — old/untouched
// children silently drop out. Fine for this dedup survey; never use it as a
// completeness check. See commands/run.md Step 0.6 for the reliable alternative.
await mcp__zenhub__searchClosedIssues({ query: feedbackText }); // already-shipped fixes
// Optional: scan the existing New Issues column to spot near-dupes already staged
const newIssues = workspace.pipelines.find(p = > p.name === " New Issues " );
if (!newIssues) throw new Error(` ' New Issues ' 파이프라인 없음. 라이브: ${workspace.pipelines.map(p = > p.name).join( " , " )}`);
await mcp__zenhub__getIssuesInPipeline({ pipelineId: newIssues.id, repositoryIds: repoIds });Key Rules#
- Dedup is semantic (LLM), not token overlap — judge title+body MEANING; search BOTH
searchLatestIssues(open) andsearchClosedIssues(closed/shipped). On a match: comment / +1, never create a second issue. /cc-quality:bug-reportmust pre-check this dedup before creating — any bug-intake path runs the step-1 dedup gate first so support/Slack/store reports never bypass it (cross-link:cc-quality:bug-report).- Labels are immutable (
updateIssuecannot change labels) → setsource:<channel>,impact-<level>,needs-triage, and the type label atcreateGitHubIssuetime. For a dedup match, you can only suggest adding thesource:label manually. source:<channel>enables per-channel tracking — always persist it so triage volume per origin is queryable.impact-<level>is the Matrix impact axis, not effort — see rules/zenhub-conventions.md "Matrix Signals" (impact = immutable label; effort =setIssueEstimate, set later during refinement, not at intake).- Stage in
New Issuesonly — everypipelines.findis fail-closed (throwwith live pipeline list). Never auto-route toProduct Backlog; triage is a human gate. - 2-state model — closing a duplicate-resolved-elsewhere issue is
gh issue close+updateIssue({ state: "CLOSED" }), never a "Done"/"Closed" column move (there is no such column; "Closed" is GitHub state). See "Issue Closure Policy". --dry-runshort-circuits after dedup + preview — nocreateGitHubIssue, nomoveIssueToPipeline.