#!/usr/bin/env node /** * OmniRoute — feature-triage CLI. * * Classifies open feature-request issues into 8 buckets: * absorb, dormant, already_delivered, skip_assigned, skip_has_pr, * stale_need_details, stale_defer, closed_externally * * Output: JSON to --output path or stdout. * * Usage: * node scripts/features/feature-triage.mjs \ * --owner diegosouzapw --repo OmniRoute \ * --output _ideia/_triage.json * * Exit codes: * 0 — success * 1 — invalid args * 2 — environment precondition failed (missing gh/git/auth) * 3 — irrecoverable GitHub API failure */ import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { parseArgs } from "./lib/args.mjs"; import { defaultDeps } from "./lib/github.mjs"; import { classifyIssue } from "./lib/classify.mjs"; import { detectDelivered, resolveVersion } from "./lib/delivered.mjs"; import { isStaleNeedDetails, isStaleDefer, detectClosedExternally } from "./lib/lifecycle.mjs"; import { parseFrontmatter } from "./lib/frontmatter.mjs"; function log(verbose, msg) { if (verbose) process.stderr.write(`[triage] ${msg}\n`); } function readChangelog(path) { if (!existsSync(path)) return ""; return readFileSync(path, "utf8"); } function listIdeaFiles(ideiaDir, subdir) { const full = join(ideiaDir, subdir); if (!existsSync(full)) return []; return readdirSync(full) .filter((f) => f.endsWith(".md") && !f.endsWith(".requirements.md")) .map((f) => join(full, f)); } function loadIdeaFiles(ideiaDir) { const map = new Map(); for (const sub of ["viable", "viable/need_details", "defer"]) { for (const path of listIdeaFiles(ideiaDir, sub)) { const text = readFileSync(path, "utf8"); const meta = parseFrontmatter(text); if (meta && typeof meta.issue === "number") { map.set(meta.issue, { path, meta, subdir: sub, mtime: statSync(path).mtime }); } } } return map; } async function main(argv, env, deps = defaultDeps) { let args; try { args = parseArgs(argv, env); } catch (e) { process.stderr.write(`Error: ${e.message}\n`); process.exit(1); } log(args.verbose, `owner=${args.owner} repo=${args.repo}`); let nums; try { const labelNums = deps.ghIssueListNumbers(args.owner, args.repo, "enhancement"); const titleNums = deps.ghIssueListFeatureTitled(args.owner, args.repo); nums = [...new Set([...labelNums, ...titleNums])]; } catch (e) { process.stderr.write(`gh failed: ${e.message}\n`); process.exit(2); } if (args.onlyIssues.length > 0) { nums = nums.filter((n) => args.onlyIssues.includes(n)); } log(args.verbose, `fetched ${nums.length} issue numbers`); const ideaFiles = loadIdeaFiles(args.ideiaDir); log(args.verbose, `loaded ${ideaFiles.size} existing idea files with frontmatter`); const buckets = { absorb: [], dormant: [], already_delivered: [], skip_assigned: [], skip_has_pr: [], stale_need_details: [], stale_defer: [], closed_externally: [], }; const warnings = []; const now = new Date(); const changelogText = readChangelog(args.changelog); const releaseBranch = deps.gitCurrentReleaseBranch(); const tagsByDate = deps.gitTagsByDate(); const thresholds = { quarantineDays: args.quarantineDays, overrideThumbs: args.overrideThumbs, overrideCommenters: args.overrideCommenters, }; // First: externally-closed cleanup based on existing files for (const [num, info] of ideaFiles) { if (nums.includes(num)) continue; try { const issue = deps.ghIssueView(args.owner, args.repo, num); const ext = detectClosedExternally(issue, info.meta); if (ext.flagged) { buckets.closed_externally.push({ number: num, file: relative(process.cwd(), info.path), closed_at: ext.closedAt, state_reason: ext.stateReason, }); if (ext.warningMessage) { warnings.push({ level: "warn", issue: num, message: ext.warningMessage }); } } } catch (e) { warnings.push({ level: "warn", issue: num, message: `fetch failed: ${e.message}` }); } } // Open issues for (const num of nums) { let issue; try { issue = deps.ghIssueView(args.owner, args.repo, num); } catch (e) { warnings.push({ level: "warn", issue: num, message: `fetch failed: ${e.message}` }); continue; } // Synthesize timelineItems from open PR search (gh issue view doesn't expose timelineItems) let openPrs = []; try { openPrs = deps.ghPrSearchOpen(args.owner, args.repo, num); } catch (e) { warnings.push({ level: "warn", issue: num, message: `open PR search failed: ${e.message}` }); } issue.timelineItems = openPrs.map((pr) => ({ __typename: "CrossReferencedEvent", source: { __typename: "PullRequest", state: "OPEN", number: pr.number }, })); const mergedPrs = deps.ghPrSearchMerged(args.owner, args.repo, num); const gitCommits = deps.gitLogGrep(`#${num}`).filter((c) => deps.gitIsAncestor(c.hash, "main")); const del = detectDelivered(num, { mergedPrs, changelog: changelogText, gitCommits }); if (del.confidence === "high" || del.confidence === "medium") { const mergedAt = del.evidence.pr_merged?.merged_at || gitCommits[0]?.date; const ver = mergedAt ? resolveVersion(new Date(mergedAt), tagsByDate, releaseBranch) : { version: releaseBranch || "unreleased", version_source: "branch_unreleased" }; buckets.already_delivered.push({ number: num, title: issue.title, author: issue.author?.login, confidence: del.confidence, evidence: del.evidence, version: ver.version, version_source: ver.version_source, }); continue; } if (del.confidence === "low") { warnings.push({ level: "warn", issue: num, message: "weak delivery signal — manual verification recommended", }); } const c = classifyIssue(issue, thresholds, now); const entry = { number: num, title: issue.title, url: issue.url, author: issue.author?.login, created_at: issue.createdAt, }; const fileInfo = ideaFiles.get(num); if (c.bucket === "absorb") { buckets.absorb.push({ ...entry, age_days: c.ageDays, thumbs: c.thumbs, commenters: c.commenters, labels: (issue.labels ?? []).map((l) => l.name), reason: c.reason, existing_idea_file: fileInfo ? relative(process.cwd(), fileInfo.path) : null, last_synced_comment_id: fileInfo?.meta?.last_synced_comment_id ?? null, }); } else if (c.bucket === "dormant") { buckets.dormant.push({ number: num, title: issue.title, age_days: c.ageDays, thumbs: c.thumbs, commenters: c.commenters, reason: c.reason, }); } else if (c.bucket === "skip_assigned") { buckets.skip_assigned.push({ number: num, title: issue.title, assignees: c.assignees }); } else if (c.bucket === "skip_has_pr") { buckets.skip_has_pr.push({ number: num, title: issue.title, linked_prs: c.linkedPrs }); } } // Lifecycle: need_details stale + defer stale for (const [num, info] of ideaFiles) { if (info.subdir === "viable/need_details") { try { const issue = deps.ghIssueView(args.owner, args.repo, num); const s = isStaleNeedDetails(issue, args.staleNeedsDays, now); if (s.stale) { buckets.stale_need_details.push({ number: num, title: issue.title, file: relative(process.cwd(), info.path), days_silent: s.daysSilent, last_author_activity: s.lastAuthorActivity, }); } } catch (e) { warnings.push({ level: "warn", issue: num, message: `need_details check failed: ${e.message}`, }); } } else if (info.subdir === "defer") { const s = isStaleDefer(info.meta, args.staleDeferDays, now, info.mtime); if (s.stale) { buckets.stale_defer.push({ number: num, file: relative(process.cwd(), info.path), days_in_defer: s.daysInDefer, deferred_at: info.meta?.snapshot?.classified_at || info.mtime.toISOString(), }); } } } const counts = { total_fetched: nums.length }; for (const k of Object.keys(buckets)) counts[k] = buckets[k].length; const out = { metadata: { run_at: now.toISOString(), owner: args.owner, repo: args.repo, thresholds: { quarantine_days: args.quarantineDays, override_thumbs: args.overrideThumbs, override_commenters: args.overrideCommenters, stale_needs_days: args.staleNeedsDays, stale_defer_days: args.staleDeferDays, }, }, counts, buckets, warnings, }; const json = JSON.stringify(out, null, 2); if (args.dryRun) { log(true, "--dry-run: not writing output"); } else if (args.output) { writeFileSync(args.output, json); log(args.verbose, `wrote ${args.output}`); } else { process.stdout.write(json + "\n"); } process.exit(0); } const invokedDirectly = import.meta.url === `file://${process.argv[1]}`; if (invokedDirectly) { main(process.argv.slice(2), process.env).catch((e) => { process.stderr.write(`Fatal: ${e.stack || e.message}\n`); process.exit(3); }); } export { main };