Spaces:
Runtime error
Runtime error
| /** | |
| * 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 }; | |