chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+300
@@ -0,0 +1,300 @@
|
||||
#!/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 };
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Args parser for feature-triage CLI.
|
||||
* Precedence: CLI flag > env var > default.
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
quarantineDays: 14,
|
||||
overrideThumbs: 5,
|
||||
overrideCommenters: 3,
|
||||
staleNeedsDays: 30,
|
||||
staleDeferDays: 90,
|
||||
ideiaDir: "_ideia",
|
||||
changelog: "CHANGELOG.md",
|
||||
output: null,
|
||||
dryRun: false,
|
||||
verbose: false,
|
||||
onlyIssues: [],
|
||||
};
|
||||
|
||||
const ENV_MAP = {
|
||||
quarantineDays: "FEATURE_QUARANTINE_DAYS",
|
||||
overrideThumbs: "FEATURE_OVERRIDE_THUMBS",
|
||||
overrideCommenters: "FEATURE_OVERRIDE_COMMENTERS",
|
||||
staleNeedsDays: "FEATURE_STALE_NEEDS_DAYS",
|
||||
staleDeferDays: "FEATURE_STALE_DEFER_DAYS",
|
||||
};
|
||||
|
||||
function takeNext(argv, i) {
|
||||
if (i + 1 >= argv.length) {
|
||||
throw new Error(`${argv[i]} requires a value`);
|
||||
}
|
||||
return argv[i + 1];
|
||||
}
|
||||
|
||||
export function parseArgs(argv, env = process.env) {
|
||||
const out = { ...DEFAULTS, owner: null, repo: null };
|
||||
|
||||
for (const [key, envKey] of Object.entries(ENV_MAP)) {
|
||||
if (env[envKey] !== undefined) {
|
||||
const n = Number(env[envKey]);
|
||||
if (Number.isFinite(n)) out[key] = n;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
switch (a) {
|
||||
case "--owner":
|
||||
out.owner = takeNext(argv, i);
|
||||
i++;
|
||||
break;
|
||||
case "--repo":
|
||||
out.repo = takeNext(argv, i);
|
||||
i++;
|
||||
break;
|
||||
case "--quarantine-days":
|
||||
out.quarantineDays = Number(takeNext(argv, i));
|
||||
i++;
|
||||
break;
|
||||
case "--override-thumbs":
|
||||
out.overrideThumbs = Number(takeNext(argv, i));
|
||||
i++;
|
||||
break;
|
||||
case "--override-commenters":
|
||||
out.overrideCommenters = Number(takeNext(argv, i));
|
||||
i++;
|
||||
break;
|
||||
case "--stale-needs-days":
|
||||
out.staleNeedsDays = Number(takeNext(argv, i));
|
||||
i++;
|
||||
break;
|
||||
case "--stale-defer-days":
|
||||
out.staleDeferDays = Number(takeNext(argv, i));
|
||||
i++;
|
||||
break;
|
||||
case "--ideia-dir":
|
||||
out.ideiaDir = takeNext(argv, i);
|
||||
i++;
|
||||
break;
|
||||
case "--changelog":
|
||||
out.changelog = takeNext(argv, i);
|
||||
i++;
|
||||
break;
|
||||
case "--output":
|
||||
out.output = takeNext(argv, i);
|
||||
i++;
|
||||
break;
|
||||
case "--dry-run":
|
||||
out.dryRun = true;
|
||||
break;
|
||||
case "--verbose":
|
||||
out.verbose = true;
|
||||
break;
|
||||
case "--only-issues":
|
||||
out.onlyIssues = takeNext(argv, i)
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter(Number.isFinite);
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown arg: ${a}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.owner) throw new Error("--owner is required");
|
||||
if (!out.repo) throw new Error("--repo is required");
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Issue classification: quarantine filter + engagement override + skip rules.
|
||||
*/
|
||||
|
||||
const HARDCODED_BOTS = new Set(["github-actions", "dependabot", "renovate", "claude", "copilot"]);
|
||||
|
||||
export function isBot(user) {
|
||||
if (!user) return false;
|
||||
if (user.__typename === "Bot") return true;
|
||||
const login = user.login || "";
|
||||
if (login.endsWith("[bot]")) return true;
|
||||
if (HARDCODED_BOTS.has(login)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function countThumbs(reactionGroups) {
|
||||
if (!Array.isArray(reactionGroups)) return 0;
|
||||
const g = reactionGroups.find((rg) => rg.content === "THUMBS_UP");
|
||||
return g?.users?.totalCount ?? 0;
|
||||
}
|
||||
|
||||
function uniqueCommenters(comments, authorLogin) {
|
||||
if (!Array.isArray(comments)) return [];
|
||||
const seen = new Set();
|
||||
for (const c of comments) {
|
||||
const u = c.author;
|
||||
if (!u || !u.login) continue;
|
||||
if (u.login === authorLogin) continue;
|
||||
if (isBot(u)) continue;
|
||||
seen.add(u.login);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function openLinkedPrs(timelineItems) {
|
||||
if (!Array.isArray(timelineItems)) return [];
|
||||
const result = [];
|
||||
for (const item of timelineItems) {
|
||||
if (item.__typename !== "CrossReferencedEvent") continue;
|
||||
const src = item.source;
|
||||
if (!src || src.__typename !== "PullRequest") continue;
|
||||
if (String(src.state).toLowerCase() !== "open") continue;
|
||||
result.push({ number: src.number, state: "open" });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function classifyIssue(issue, thresholds, now = new Date()) {
|
||||
const assignees = issue.assignees ?? [];
|
||||
if (assignees.length > 0) {
|
||||
return {
|
||||
bucket: "skip_assigned",
|
||||
reason: "issue has assignees",
|
||||
assignees: assignees.map((a) => a.login),
|
||||
};
|
||||
}
|
||||
|
||||
const linkedPrs = openLinkedPrs(issue.timelineItems);
|
||||
if (linkedPrs.length > 0) {
|
||||
return {
|
||||
bucket: "skip_has_pr",
|
||||
reason: "issue has open linked PR",
|
||||
linkedPrs,
|
||||
};
|
||||
}
|
||||
|
||||
const createdAt = new Date(issue.createdAt).getTime();
|
||||
let ageDays = Math.floor((now.getTime() - createdAt) / 86400_000);
|
||||
if (ageDays < 0) ageDays = 0;
|
||||
|
||||
const thumbs = countThumbs(issue.reactionGroups);
|
||||
const commenterLogins = uniqueCommenters(issue.comments, issue.author?.login);
|
||||
const commenters = commenterLogins.length;
|
||||
|
||||
const meta = { ageDays, thumbs, commenters };
|
||||
|
||||
if (ageDays >= thresholds.quarantineDays) {
|
||||
return { bucket: "absorb", reason: `age>=${thresholds.quarantineDays}`, ...meta };
|
||||
}
|
||||
|
||||
const overrides = [];
|
||||
if (thumbs >= thresholds.overrideThumbs) overrides.push("thumbs");
|
||||
if (commenters >= thresholds.overrideCommenters) overrides.push("commenters");
|
||||
if (overrides.length > 0) {
|
||||
return { bucket: "absorb", reason: `override:${overrides.join("+")}`, ...meta };
|
||||
}
|
||||
|
||||
return {
|
||||
bucket: "dormant",
|
||||
reason: `age<${thresholds.quarantineDays} && thumbs<${thresholds.overrideThumbs} && commenters<${thresholds.overrideCommenters}`,
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Delivery detection: PR merged + CHANGELOG + git log, with confidence grading.
|
||||
*/
|
||||
|
||||
const VERSION_HEADER_RE = /^##\s+\[?(\d+\.\d+\.\d+)\]?/;
|
||||
|
||||
export function parseChangelog(text, issueNumber) {
|
||||
if (typeof text !== "string") return null;
|
||||
if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null;
|
||||
const needle = `#${issueNumber}`;
|
||||
const lines = text.split("\n");
|
||||
|
||||
let currentSection = null;
|
||||
let currentVersion = null;
|
||||
for (const line of lines) {
|
||||
const headerMatch = line.match(VERSION_HEADER_RE);
|
||||
if (headerMatch) {
|
||||
currentSection = line.trim();
|
||||
currentVersion = headerMatch[1];
|
||||
continue;
|
||||
}
|
||||
if (!currentSection) continue;
|
||||
// Match #N with word boundary: look for needle followed by non-word char or end
|
||||
const idx = line.indexOf(needle);
|
||||
if (idx !== -1) {
|
||||
const nextIdx = idx + needle.length;
|
||||
const nextChar = line[nextIdx];
|
||||
const isWordBoundary = nextIdx >= line.length || /\W/.test(nextChar);
|
||||
if (isWordBoundary) {
|
||||
return {
|
||||
section: currentSection,
|
||||
version: currentVersion,
|
||||
line: line.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExplicitClose(pr, issueNumber) {
|
||||
const text = `${pr.title ?? ""}\n${pr.body ?? ""}`;
|
||||
const re = new RegExp(`\\b(closes?|fixes?|fixed|resolves?|resolved)\\s+#${issueNumber}\\b`, "i");
|
||||
return re.test(text);
|
||||
}
|
||||
|
||||
function justMentions(pr, issueNumber) {
|
||||
const text = `${pr.title ?? ""}\n${pr.body ?? ""}`;
|
||||
return new RegExp(`#${issueNumber}\\b`).test(text);
|
||||
}
|
||||
|
||||
export function detectDelivered(issueNumber, signals) {
|
||||
const { mergedPrs = [], changelog = "", gitCommits = [] } = signals;
|
||||
|
||||
const closesPr = mergedPrs.find((p) => isExplicitClose(p, issueNumber));
|
||||
const mentionPr = closesPr || mergedPrs.find((p) => justMentions(p, issueNumber));
|
||||
const changelogHit = parseChangelog(changelog, issueNumber);
|
||||
const gitHit = gitCommits.length > 0 ? gitCommits[0] : null;
|
||||
|
||||
const A = !!closesPr;
|
||||
const B = !!(mentionPr && !closesPr);
|
||||
const C = !!changelogHit;
|
||||
const D = !!gitHit;
|
||||
|
||||
let confidence = "none";
|
||||
if (A) confidence = "high";
|
||||
else if ((C && D) || (B && C) || (B && D)) confidence = "medium";
|
||||
else if (B || C || D) confidence = "low";
|
||||
|
||||
const evidence = {};
|
||||
if (closesPr) {
|
||||
evidence.pr_merged = {
|
||||
number: closesPr.number,
|
||||
merged_at: closesPr.mergedAt,
|
||||
ref: `closes #${issueNumber}`,
|
||||
};
|
||||
} else if (mentionPr) {
|
||||
evidence.pr_merged = {
|
||||
number: mentionPr.number,
|
||||
merged_at: mentionPr.mergedAt,
|
||||
ref: `mentions #${issueNumber}`,
|
||||
};
|
||||
}
|
||||
if (changelogHit) evidence.changelog_section = changelogHit.section;
|
||||
if (gitHit) evidence.git_commits = gitCommits.slice(0, 5).map((c) => c.hash);
|
||||
|
||||
return { confidence, evidence };
|
||||
}
|
||||
|
||||
export function resolveVersion(mergedAt, tagsByDate, currentReleaseBranch) {
|
||||
const mergedTime = mergedAt instanceof Date ? mergedAt.getTime() : new Date(mergedAt).getTime();
|
||||
const after = tagsByDate.find((t) => t.date.getTime() >= mergedTime);
|
||||
if (after) {
|
||||
return { version: after.name, version_source: "tag_after_merge" };
|
||||
}
|
||||
return {
|
||||
version: currentReleaseBranch || "unreleased",
|
||||
version_source: "branch_unreleased",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Minimal YAML frontmatter reader/writer for idea files.
|
||||
* Supports: scalars (string/number/bool), nested object (one level), inline arrays [a, b].
|
||||
* Not a general YAML lib — kept simple intentionally.
|
||||
*/
|
||||
|
||||
const DELIM = "---";
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if (s === "true") return true;
|
||||
if (s === "false") return false;
|
||||
if (s === "null") return null;
|
||||
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
||||
if (s.startsWith("[") && s.endsWith("]")) {
|
||||
const inner = s.slice(1, -1).trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(",").map((x) => parseScalar(x.trim()));
|
||||
}
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(text) {
|
||||
if (typeof text !== "string") return null;
|
||||
if (!text.startsWith(`${DELIM}\n`)) return null;
|
||||
const rest = text.slice(DELIM.length + 1);
|
||||
const closeIdx = rest.indexOf(`\n${DELIM}`);
|
||||
if (closeIdx < 0) return null;
|
||||
const body = rest.slice(0, closeIdx);
|
||||
|
||||
const out = {};
|
||||
let currentNested = null;
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
if (line.startsWith(" ") && currentNested) {
|
||||
const m = line.match(/^\s{2}([\w_-]+):\s*(.*)$/);
|
||||
if (m) currentNested[m[1]] = parseScalar(m[2]);
|
||||
continue;
|
||||
}
|
||||
const m = line.match(/^([\w_-]+):\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
const val = m[2];
|
||||
if (val === "") {
|
||||
currentNested = {};
|
||||
out[key] = currentNested;
|
||||
} else {
|
||||
currentNested = null;
|
||||
out[key] = parseScalar(val);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeScalar(v) {
|
||||
if (v === null) return "null";
|
||||
if (typeof v === "boolean") return String(v);
|
||||
if (typeof v === "number") return String(v);
|
||||
if (Array.isArray(v)) return `[${v.map(serializeScalar).join(", ")}]`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function serializeFrontmatter(meta, body) {
|
||||
const lines = [DELIM];
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
lines.push(`${k}:`);
|
||||
for (const [nk, nv] of Object.entries(v)) {
|
||||
lines.push(` ${nk}: ${serializeScalar(nv)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${k}: ${serializeScalar(v)}`);
|
||||
}
|
||||
}
|
||||
lines.push(DELIM, "", body);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function stripFrontmatter(text) {
|
||||
if (typeof text !== "string") return text;
|
||||
if (!text.startsWith(`${DELIM}\n`)) return text;
|
||||
const rest = text.slice(DELIM.length + 1);
|
||||
const closeIdx = rest.indexOf(`\n${DELIM}`);
|
||||
if (closeIdx < 0) return text;
|
||||
return rest.slice(closeIdx + DELIM.length + 1).replace(/^\n+/, "");
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Thin wrappers around `gh` and `git` CLIs.
|
||||
* Functions return parsed JSON or strings. They throw on non-zero exit.
|
||||
* Tests inject fakes via the `deps` parameter pattern (see classify.mjs etc.).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
function runJson(cmd, args) {
|
||||
const out = execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
try {
|
||||
return JSON.parse(out);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON from ${cmd}: ${e.message}\nOutput (first 200 chars): ${out.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runText(cmd, args) {
|
||||
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
export function ghIssueListNumbers(owner, repo, label) {
|
||||
return runJson("gh", [
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--state",
|
||||
"open",
|
||||
"-l",
|
||||
label,
|
||||
"--limit",
|
||||
"500",
|
||||
"--json",
|
||||
"number",
|
||||
]).map((x) => x.number);
|
||||
}
|
||||
|
||||
export function ghIssueListFeatureTitled(owner, repo) {
|
||||
const items = runJson("gh", [
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--state",
|
||||
"open",
|
||||
"--limit",
|
||||
"500",
|
||||
"--json",
|
||||
"number,title",
|
||||
]);
|
||||
return items.filter((i) => /\[feature\]|feature request/i.test(i.title)).map((i) => i.number);
|
||||
}
|
||||
|
||||
export function ghIssueView(owner, repo, number) {
|
||||
return runJson("gh", [
|
||||
"issue",
|
||||
"view",
|
||||
String(number),
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--json",
|
||||
"number,title,url,body,state,stateReason,labels,author,assignees,createdAt,closedAt,comments,reactionGroups",
|
||||
]);
|
||||
}
|
||||
|
||||
export function ghPrSearchOpen(owner, repo, issueNumber) {
|
||||
return runJson("gh", [
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--state",
|
||||
"open",
|
||||
"--search",
|
||||
`#${issueNumber}`,
|
||||
"--json",
|
||||
"number,title,body",
|
||||
"--limit",
|
||||
"10",
|
||||
]);
|
||||
}
|
||||
|
||||
export function ghPrSearchMerged(owner, repo, issueNumber) {
|
||||
return runJson("gh", [
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
`${owner}/${repo}`,
|
||||
"--state",
|
||||
"merged",
|
||||
"--search",
|
||||
`#${issueNumber}`,
|
||||
"--json",
|
||||
"number,title,body,mergedAt,mergeCommit",
|
||||
"--limit",
|
||||
"20",
|
||||
]);
|
||||
}
|
||||
|
||||
export function gitTagsByDate() {
|
||||
const out = runText("git", [
|
||||
"tag",
|
||||
"--sort=creatordate",
|
||||
"--format=%(creatordate:iso8601)|%(refname:short)",
|
||||
]);
|
||||
return out
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [date, name] = line.split("|");
|
||||
return { date: new Date(date), name };
|
||||
})
|
||||
.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name));
|
||||
}
|
||||
|
||||
export function gitLogGrep(pattern) {
|
||||
try {
|
||||
const out = runText("git", [
|
||||
"log",
|
||||
"--all",
|
||||
`--grep=${pattern}`,
|
||||
"--format=%H|%cI|%s",
|
||||
"--regexp-ignore-case",
|
||||
]);
|
||||
if (!out) return [];
|
||||
return out.split("\n").map((line) => {
|
||||
const [hash, date, ...rest] = line.split("|");
|
||||
return { hash, date: new Date(date), subject: rest.join("|") };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function gitIsAncestor(hash, ref) {
|
||||
try {
|
||||
execFileSync("git", ["merge-base", "--is-ancestor", hash, ref], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function gitCurrentReleaseBranch() {
|
||||
try {
|
||||
const out = runText("git", ["branch", "--format=%(refname:short)"]);
|
||||
const branches = out.split("\n").filter((b) => /^release\/v\d+\.\d+\.\d+$/.test(b));
|
||||
if (branches.length === 0) return null;
|
||||
const current = runText("git", ["branch", "--show-current"]);
|
||||
if (branches.includes(current)) return current;
|
||||
branches.sort((a, b) => {
|
||||
const av = a.replace("release/v", "").split(".").map(Number);
|
||||
const bv = b.replace("release/v", "").split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return branches[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultDeps = {
|
||||
ghIssueListNumbers,
|
||||
ghIssueListFeatureTitled,
|
||||
ghIssueView,
|
||||
ghPrSearchMerged,
|
||||
ghPrSearchOpen,
|
||||
gitTagsByDate,
|
||||
gitLogGrep,
|
||||
gitIsAncestor,
|
||||
gitCurrentReleaseBranch,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Lifecycle detectors: stale need_details, stale defer, closed-externally.
|
||||
*/
|
||||
|
||||
export function isStaleNeedDetails(issue, thresholdDays, now = new Date()) {
|
||||
const authorLogin = issue.author?.login;
|
||||
let lastAuthorActivity = issue.createdAt;
|
||||
for (const c of issue.comments ?? []) {
|
||||
if (c.author?.login === authorLogin) {
|
||||
if (new Date(c.createdAt).getTime() > new Date(lastAuthorActivity).getTime()) {
|
||||
lastAuthorActivity = c.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
const daysSilent = Math.floor(
|
||||
(now.getTime() - new Date(lastAuthorActivity).getTime()) / 86400_000
|
||||
);
|
||||
return {
|
||||
stale: daysSilent >= thresholdDays,
|
||||
daysSilent,
|
||||
lastAuthorActivity,
|
||||
};
|
||||
}
|
||||
|
||||
export function isStaleDefer(meta, thresholdDays, now = new Date(), fallbackDate = null) {
|
||||
const classifiedAt = meta?.snapshot?.classified_at || fallbackDate;
|
||||
if (!classifiedAt) return { stale: false, daysInDefer: 0 };
|
||||
const daysInDefer = Math.floor((now.getTime() - new Date(classifiedAt).getTime()) / 86400_000);
|
||||
return { stale: daysInDefer >= thresholdDays, daysInDefer };
|
||||
}
|
||||
|
||||
export function detectClosedExternally(issue, meta) {
|
||||
const issueState = String(issue.state ?? "").toUpperCase();
|
||||
const snapshotState = String(meta?.snapshot?.state ?? "open").toLowerCase();
|
||||
if (issueState !== "CLOSED") return { flagged: false };
|
||||
if (snapshotState === "closed") return { flagged: false };
|
||||
const stateReason = issue.stateReason ?? null;
|
||||
const out = { flagged: true, closedAt: issue.closedAt, stateReason };
|
||||
if (stateReason === "COMPLETED") {
|
||||
out.warningMessage = "possibly delivered by external work";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Incremental re-sync of idea files: append new comments only,
|
||||
* update frontmatter snapshot.
|
||||
*/
|
||||
import { parseFrontmatter, serializeFrontmatter, stripFrontmatter } from "./frontmatter.mjs";
|
||||
import { isBot } from "./classify.mjs";
|
||||
|
||||
function countThumbs(reactionGroups) {
|
||||
if (!Array.isArray(reactionGroups)) return 0;
|
||||
const g = reactionGroups.find((rg) => rg.content === "THUMBS_UP");
|
||||
return g?.users?.totalCount ?? 0;
|
||||
}
|
||||
|
||||
function uniqueCommenters(comments, authorLogin) {
|
||||
const seen = new Set();
|
||||
for (const c of comments ?? []) {
|
||||
const u = c.author;
|
||||
if (!u || !u.login) continue;
|
||||
if (u.login === authorLogin) continue;
|
||||
if (isBot(u)) continue;
|
||||
seen.add(u.login);
|
||||
}
|
||||
return seen.size;
|
||||
}
|
||||
|
||||
function renderComments(comments) {
|
||||
return comments
|
||||
.map(
|
||||
(c) =>
|
||||
`- **@${c.author?.login ?? "unknown"}** (${c.createdAt}):\n > ${(c.body ?? "").split("\n").join("\n > ")}`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function appendComments(body, newComments) {
|
||||
const heading = "## 💬 Community Discussion";
|
||||
let idx = body.indexOf(heading);
|
||||
if (idx < 0) idx = body.indexOf("## Community Discussion");
|
||||
if (idx < 0) {
|
||||
return body + `\n\n${heading}\n\n` + renderComments(newComments);
|
||||
}
|
||||
const after = body.slice(idx);
|
||||
const nextHeaderMatch = after.slice(heading.length).match(/^##\s+/m);
|
||||
const insertAt = nextHeaderMatch ? idx + heading.length + nextHeaderMatch.index : body.length;
|
||||
const insertBlock = "\n\n" + renderComments(newComments) + "\n";
|
||||
return body.slice(0, insertAt) + insertBlock + body.slice(insertAt);
|
||||
}
|
||||
|
||||
export function resyncIdeaFile(text, issue, now = new Date(), opts = {}) {
|
||||
const meta = parseFrontmatter(text);
|
||||
if (!meta) return { changed: false, text };
|
||||
|
||||
const lastSyncedId = Number(meta.last_synced_comment_id ?? 0);
|
||||
const allComments = issue.comments ?? [];
|
||||
const newComments = allComments.filter((c) => Number(c.databaseId) > lastSyncedId);
|
||||
|
||||
if (newComments.length === 0) {
|
||||
return { changed: false, text };
|
||||
}
|
||||
|
||||
const body = stripFrontmatter(text);
|
||||
const newBody = appendComments(body, newComments);
|
||||
|
||||
const newMeta = {
|
||||
...meta,
|
||||
last_synced_at: now.toISOString(),
|
||||
last_synced_comment_id: Math.max(...newComments.map((c) => Number(c.databaseId))),
|
||||
snapshot: {
|
||||
...(meta.snapshot ?? {}),
|
||||
thumbs: countThumbs(issue.reactionGroups),
|
||||
commenters: uniqueCommenters(allComments, issue.author?.login),
|
||||
labels: (issue.labels ?? []).map((l) => l.name).filter(Boolean),
|
||||
state: String(issue.state ?? "open").toLowerCase(),
|
||||
},
|
||||
};
|
||||
|
||||
const out = serializeFrontmatter(newMeta, newBody);
|
||||
|
||||
const needsReclassification = !!(
|
||||
opts.inNeedDetails && newComments.some((c) => c.author?.login === issue.author?.login)
|
||||
);
|
||||
|
||||
return { changed: true, text: out, needsReclassification };
|
||||
}
|
||||
Reference in New Issue
Block a user