Skip to content

Instantly share code, notes, and snippets.

@L4Ph
Created June 16, 2026 11:45
Show Gist options
  • Select an option

  • Save L4Ph/a102fb9066f6a67b63edb7189a9ac52d to your computer and use it in GitHub Desktop.

Select an option

Save L4Ph/a102fb9066f6a67b63edb7189a9ac52d to your computer and use it in GitHub Desktop.
issue管理
#!/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