From 1110ec9d371f8a8d5cba147cbef2958009fa9fe3 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 03:07:00 -0300 Subject: [PATCH] feat(triage): add CLI entrypoint composing all triage modules --- scripts/features/feature-triage.mjs | 288 ++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100755 scripts/features/feature-triage.mjs diff --git a/scripts/features/feature-triage.mjs b/scripts/features/feature-triage.mjs new file mode 100755 index 0000000000..4b91ebd894 --- /dev/null +++ b/scripts/features/feature-triage.mjs @@ -0,0 +1,288 @@ +#!/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; + } + + 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 };