Created
June 16, 2026 11:45
-
-
Save L4Ph/a102fb9066f6a67b63edb7189a9ac52d to your computer and use it in GitHub Desktop.
issue管理
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // Local issue management script. Invoke via `vp run issues <subcommand>`. | |
| // Conventions live in .claude/rules/issues.md. | |
| import { readFileSync, writeFileSync, readdirSync } from "node:fs"; | |
| import { dirname, join } from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const ISSUES_DIR = dirname(fileURLToPath(import.meta.url)); | |
| const STATES = ["open", "completed", "closed"] as const; | |
| type State = (typeof STATES)[number]; | |
| const CLOSED_REASONS = ["wontfix", "duplicate", "superseded"] as const; | |
| type ClosedReason = (typeof CLOSED_REASONS)[number]; | |
| type Frontmatter = { | |
| state: State; | |
| created: string; | |
| labels?: string[]; | |
| depends_on?: string[]; | |
| pr?: number; | |
| completed_at?: string; | |
| closed_reason?: ClosedReason; | |
| }; | |
| type IssueFile = { | |
| id: string; | |
| filename: string; | |
| frontmatter: Frontmatter; | |
| body: string; | |
| }; | |
| const FRONTMATTER_ORDER: (keyof Frontmatter)[] = [ | |
| "state", | |
| "created", | |
| "labels", | |
| "depends_on", | |
| "pr", | |
| "completed_at", | |
| "closed_reason", | |
| ]; | |
| const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; | |
| const FILENAME_RE = /^(\d{8}-\d+)-([a-z0-9][a-z0-9-]*)\.md$/; | |
| const ID_RE = /^\d{8}-\d+$/; | |
| function todayJST(): { dateCompact: string; dateIso: string } { | |
| const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo" }); | |
| const iso = fmt.format(new Date()); | |
| return { dateCompact: iso.replaceAll("-", ""), dateIso: iso }; | |
| } | |
| function listIssueFiles(): string[] { | |
| return readdirSync(ISSUES_DIR) | |
| .filter((f) => f.endsWith(".md") && FILENAME_RE.test(f)) | |
| .sort(); | |
| } | |
| function parseFrontmatter( | |
| text: string, | |
| filename: string, | |
| ): { frontmatter: Frontmatter; body: string } { | |
| if (!text.startsWith("---\n")) { | |
| throw new Error(`${filename}: missing leading frontmatter delimiter`); | |
| } | |
| const end = text.indexOf("\n---\n", 4); | |
| if (end === -1) { | |
| throw new Error(`${filename}: missing closing frontmatter delimiter`); | |
| } | |
| const yamlBlock = text.slice(4, end); | |
| const body = text.slice(end + 5); | |
| const fm: Partial<Frontmatter> = {}; | |
| for (const rawLine of yamlBlock.split("\n")) { | |
| const line = rawLine.trimEnd(); | |
| if (line === "" || line.startsWith("#")) continue; | |
| const colon = line.indexOf(":"); | |
| if (colon === -1) { | |
| throw new Error(`${filename}: malformed frontmatter line: ${line}`); | |
| } | |
| const key = line.slice(0, colon).trim(); | |
| const value = line.slice(colon + 1).trim(); | |
| assignField(fm, key, value, filename); | |
| } | |
| if (!fm.state) throw new Error(`${filename}: state is required`); | |
| if (!fm.created) throw new Error(`${filename}: created is required`); | |
| return { frontmatter: fm as Frontmatter, body }; | |
| } | |
| function assignField(fm: Partial<Frontmatter>, key: string, value: string, filename: string): void { | |
| switch (key) { | |
| case "state": | |
| if (!STATES.includes(value as State)) { | |
| throw new Error(`${filename}: invalid state "${value}". Allowed: ${STATES.join(", ")}`); | |
| } | |
| fm.state = value as State; | |
| break; | |
| case "created": | |
| assertIsoDate(value, `${filename}: created`); | |
| fm.created = value; | |
| break; | |
| case "labels": | |
| fm.labels = parseInlineArray(value, filename); | |
| break; | |
| case "depends_on": | |
| fm.depends_on = parseInlineArray(value, filename); | |
| for (const id of fm.depends_on) { | |
| if (!ID_RE.test(id)) throw new Error(`${filename}: depends_on contains invalid id "${id}"`); | |
| } | |
| break; | |
| case "pr": { | |
| const n = Number(value); | |
| if (!Number.isInteger(n) || n <= 0) { | |
| throw new Error(`${filename}: pr must be a positive integer`); | |
| } | |
| fm.pr = n; | |
| break; | |
| } | |
| case "completed_at": | |
| assertIsoDate(value, `${filename}: completed_at`); | |
| fm.completed_at = value; | |
| break; | |
| case "closed_reason": | |
| if (!CLOSED_REASONS.includes(value as ClosedReason)) { | |
| throw new Error( | |
| `${filename}: invalid closed_reason "${value}". Allowed: ${CLOSED_REASONS.join(", ")}`, | |
| ); | |
| } | |
| fm.closed_reason = value as ClosedReason; | |
| break; | |
| default: | |
| throw new Error(`${filename}: unknown frontmatter key "${key}"`); | |
| } | |
| } | |
| function parseInlineArray(value: string, filename: string): string[] { | |
| if (!value.startsWith("[") || !value.endsWith("]")) { | |
| throw new Error(`${filename}: array value must use inline syntax [a, b, c]: ${value}`); | |
| } | |
| const inner = value.slice(1, -1).trim(); | |
| if (inner === "") return []; | |
| return inner | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter((s) => s !== ""); | |
| } | |
| function assertIsoDate(value: string, ctx: string): void { | |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { | |
| throw new Error(`${ctx}: must be yyyy-MM-dd, got "${value}"`); | |
| } | |
| } | |
| function serializeFrontmatter(fm: Frontmatter): string { | |
| const lines: string[] = ["---"]; | |
| for (const key of FRONTMATTER_ORDER) { | |
| const v = fm[key]; | |
| if (v === undefined) continue; | |
| if (Array.isArray(v)) { | |
| lines.push(`${key}: [${v.join(", ")}]`); | |
| } else { | |
| lines.push(`${key}: ${v}`); | |
| } | |
| } | |
| lines.push("---", ""); | |
| return lines.join("\n"); | |
| } | |
| function loadIssue(filename: string): IssueFile { | |
| const text = readFileSync(join(ISSUES_DIR, filename), "utf8"); | |
| const { frontmatter, body } = parseFrontmatter(text, filename); | |
| const match = filename.match(FILENAME_RE); | |
| if (!match) throw new Error(`${filename}: filename does not match expected pattern`); | |
| return { id: match[1], filename, frontmatter, body }; | |
| } | |
| function saveIssue(issue: IssueFile): void { | |
| const text = serializeFrontmatter(issue.frontmatter) + issue.body; | |
| writeFileSync(join(ISSUES_DIR, issue.filename), text); | |
| } | |
| function findIssue(id: string): IssueFile { | |
| if (!ID_RE.test(id)) throw new Error(`invalid id "${id}". Expected yyyyMMdd-n.`); | |
| const matches = listIssueFiles().filter((f) => f.startsWith(`${id}-`)); | |
| if (matches.length === 0) throw new Error(`no issue file found for id ${id}`); | |
| if (matches.length > 1) { | |
| throw new Error( | |
| `multiple issue files share id ${id}: ${matches.join(", ")}. Run \`vp run issues validate\`.`, | |
| ); | |
| } | |
| return loadIssue(matches[0]); | |
| } | |
| // ─── subcommands ───────────────────────────────────────────────────────────── | |
| function cmdNew(slug: string): void { | |
| if (!slug) { | |
| fail('usage: issues new "<english-slug>"'); | |
| } | |
| if (!SLUG_RE.test(slug)) { | |
| fail( | |
| `slug must match ${SLUG_RE} (lowercase ASCII letters/digits/hyphens, starting with alnum). Got: "${slug}"\n` + | |
| "Translate to English first if you tried Japanese.", | |
| ); | |
| } | |
| const { dateCompact, dateIso } = todayJST(); | |
| const todays = listIssueFiles().filter((f) => f.startsWith(`${dateCompact}-`)); | |
| let maxN = 0; | |
| for (const f of todays) { | |
| const m = f.match(FILENAME_RE); | |
| if (!m) continue; | |
| const n = Number(m[1].slice(9)); | |
| if (n > maxN) maxN = n; | |
| } | |
| const n = maxN + 1; | |
| const id = `${dateCompact}-${n}`; | |
| const filename = `${id}-${slug}.md`; | |
| const path = join(ISSUES_DIR, filename); | |
| const frontmatter: Frontmatter = { state: "open", created: dateIso }; | |
| const title = slug.replaceAll("-", " ").replace(/\b\w/g, (c) => c.toUpperCase()); | |
| const body = `\n# ${title}\n\n## 背景\n\nTODO\n\n## やること\n\nTODO\n\n## 検証\n\nTODO\n`; | |
| writeFileSync(path, serializeFrontmatter(frontmatter) + body); | |
| console.log(`created ${filename}`); | |
| console.log(`id: ${id}`); | |
| } | |
| function cmdComplete(id: string, prFlag?: string, prValue?: string): void { | |
| if (!id) { | |
| fail("usage: issues complete <id> [--pr <num>]"); | |
| } | |
| let pr: number | undefined; | |
| if (prFlag === "--pr") { | |
| if (!prValue) fail("--pr requires a value"); | |
| const parsed = Number(prValue); | |
| if (!Number.isInteger(parsed) || parsed <= 0) { | |
| fail(`--pr must be a positive integer, got "${prValue}"`); | |
| } | |
| pr = parsed; | |
| } else if (prFlag !== undefined) { | |
| fail(`unknown argument "${prFlag}". usage: issues complete <id> [--pr <num>]`); | |
| } | |
| const issue = findIssue(id); | |
| if (issue.frontmatter.state !== "open") { | |
| fail(`${id} is in state ${issue.frontmatter.state}; only open issues can be completed.`); | |
| } | |
| issue.frontmatter.state = "completed"; | |
| issue.frontmatter.completed_at = todayJST().dateIso; | |
| if (pr !== undefined) issue.frontmatter.pr = pr; | |
| delete issue.frontmatter.closed_reason; | |
| saveIssue(issue); | |
| console.log(`completed ${id}${pr !== undefined ? ` (pr: ${pr})` : ""}`); | |
| } | |
| function cmdClose(id: string, reason: string): void { | |
| if (!id || !reason) | |
| fail(`usage: issues close <id> <reason> (reason: ${CLOSED_REASONS.join("|")})`); | |
| if (!CLOSED_REASONS.includes(reason as ClosedReason)) { | |
| fail(`invalid reason "${reason}". Allowed: ${CLOSED_REASONS.join(", ")}`); | |
| } | |
| const issue = findIssue(id); | |
| if (issue.frontmatter.state !== "open") { | |
| fail(`${id} is in state ${issue.frontmatter.state}; only open issues can be closed.`); | |
| } | |
| issue.frontmatter.state = "closed"; | |
| issue.frontmatter.closed_reason = reason as ClosedReason; | |
| delete issue.frontmatter.completed_at; | |
| saveIssue(issue); | |
| console.log(`closed ${id} (${reason})`); | |
| } | |
| function cmdList(args: string[]): void { | |
| let stateFilter: State | undefined = "open"; | |
| let labelFilter: string | undefined; | |
| let allStates = false; | |
| for (let i = 0; i < args.length; i++) { | |
| const a = args[i]; | |
| if (a === "--state") { | |
| const v = args[++i]; | |
| if (v === "all") { | |
| allStates = true; | |
| stateFilter = undefined; | |
| } else if (STATES.includes(v as State)) { | |
| stateFilter = v as State; | |
| } else { | |
| fail(`--state must be one of ${STATES.join("|")} or "all"`); | |
| } | |
| } else if (a === "--label") { | |
| labelFilter = args[++i]; | |
| if (!labelFilter) fail("--label requires a value"); | |
| } else { | |
| fail(`unknown argument: ${a}`); | |
| } | |
| } | |
| const files = listIssueFiles(); | |
| const rows: string[] = []; | |
| for (const f of files) { | |
| const issue = loadIssue(f); | |
| if (!allStates && stateFilter && issue.frontmatter.state !== stateFilter) continue; | |
| if (labelFilter && !(issue.frontmatter.labels ?? []).includes(labelFilter)) continue; | |
| const title = extractTitle(issue.body); | |
| const labels = issue.frontmatter.labels ? `[${issue.frontmatter.labels.join(", ")}]` : ""; | |
| rows.push(`${issue.id}\t${issue.frontmatter.state}\t${labels}\t${title}`); | |
| } | |
| if (rows.length === 0) { | |
| console.log("(no issues match)"); | |
| return; | |
| } | |
| console.log(rows.join("\n")); | |
| } | |
| function extractTitle(body: string): string { | |
| const m = body.match(/^#\s+(.+)$/m); | |
| return m ? m[1].trim() : "(no title)"; | |
| } | |
| function cmdValidate(): void { | |
| const files = listIssueFiles(); | |
| const errors: string[] = []; | |
| const issuesById = new Map<string, IssueFile[]>(); | |
| for (const f of readdirSync(ISSUES_DIR)) { | |
| if (!f.endsWith(".md")) continue; | |
| if (!FILENAME_RE.test(f)) { | |
| errors.push(`${f}: filename does not match yyyyMMdd-n-slug.md`); | |
| } | |
| } | |
| for (const f of files) { | |
| let issue: IssueFile; | |
| try { | |
| issue = loadIssue(f); | |
| } catch (e) { | |
| errors.push((e as Error).message); | |
| continue; | |
| } | |
| const list = issuesById.get(issue.id) ?? []; | |
| list.push(issue); | |
| issuesById.set(issue.id, list); | |
| const fm = issue.frontmatter; | |
| if (fm.state === "completed" && !fm.completed_at) { | |
| errors.push(`${f}: state=completed requires completed_at`); | |
| } | |
| if (fm.state === "closed" && !fm.closed_reason) { | |
| errors.push(`${f}: state=closed requires closed_reason`); | |
| } | |
| if (fm.state !== "completed" && fm.completed_at) { | |
| errors.push(`${f}: completed_at is set but state=${fm.state}`); | |
| } | |
| if (fm.state !== "closed" && fm.closed_reason) { | |
| errors.push(`${f}: closed_reason is set but state=${fm.state}`); | |
| } | |
| } | |
| for (const [id, list] of issuesById) { | |
| if (list.length > 1) { | |
| errors.push(`duplicate id ${id}: ${list.map((i) => i.filename).join(", ")}`); | |
| } | |
| } | |
| // depends_on dangling refs | |
| for (const [, list] of issuesById) { | |
| for (const issue of list) { | |
| for (const dep of issue.frontmatter.depends_on ?? []) { | |
| if (!issuesById.has(dep)) { | |
| errors.push(`${issue.filename}: depends_on references missing id ${dep}`); | |
| } | |
| } | |
| } | |
| } | |
| // depends_on cycles | |
| const cycle = findCycle(issuesById); | |
| if (cycle) { | |
| errors.push(`depends_on cycle: ${cycle.join(" → ")}`); | |
| } | |
| if (errors.length > 0) { | |
| console.error(errors.map((e) => `error: ${e}`).join("\n")); | |
| process.exit(1); | |
| } | |
| console.log(`ok (${files.length} issues)`); | |
| } | |
| function findCycle(byId: Map<string, IssueFile[]>): string[] | null { | |
| const WHITE = 0; | |
| const GRAY = 1; | |
| const BLACK = 2; | |
| const color = new Map<string, number>(); | |
| for (const id of byId.keys()) color.set(id, WHITE); | |
| const stack: string[] = []; | |
| const visit = (id: string): string[] | null => { | |
| color.set(id, GRAY); | |
| stack.push(id); | |
| const issue = byId.get(id)?.[0]; | |
| for (const dep of issue?.frontmatter.depends_on ?? []) { | |
| const c = color.get(dep); | |
| if (c === GRAY) { | |
| const cycleStart = stack.indexOf(dep); | |
| return stack.slice(cycleStart).concat(dep); | |
| } | |
| if (c === WHITE) { | |
| const found = visit(dep); | |
| if (found) return found; | |
| } | |
| } | |
| stack.pop(); | |
| color.set(id, BLACK); | |
| return null; | |
| }; | |
| for (const id of byId.keys()) { | |
| if (color.get(id) === WHITE) { | |
| const found = visit(id); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| } | |
| function fail(msg: string): never { | |
| console.error(msg); | |
| process.exit(1); | |
| } | |
| function usage(): never { | |
| console.error( | |
| [ | |
| "usage:", | |
| " vp run issues new <english-slug>", | |
| " vp run issues complete <id> [--pr <num>]", | |
| " vp run issues close <id> <reason> (reason: wontfix|duplicate|superseded)", | |
| " vp run issues list [--state <s>|all] [--label <l>]", | |
| " vp run issues validate", | |
| ].join("\n"), | |
| ); | |
| process.exit(1); | |
| } | |
| const [, , subcommand, ...rest] = process.argv; | |
| switch (subcommand) { | |
| case "new": | |
| cmdNew(rest[0]); | |
| break; | |
| case "complete": | |
| cmdComplete(rest[0], rest[1], rest[2]); | |
| break; | |
| case "close": | |
| cmdClose(rest[0], rest[1]); | |
| break; | |
| case "list": | |
| cmdList(rest); | |
| break; | |
| case "validate": | |
| cmdValidate(); | |
| break; | |
| case undefined: | |
| case "help": | |
| case "--help": | |
| case "-h": | |
| usage(); | |
| default: | |
| console.error(`unknown subcommand: ${subcommand}`); | |
| usage(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment