301 lines
9.4 KiB
JavaScript
Executable File
301 lines
9.4 KiB
JavaScript
Executable File
#!/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 };
|