chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+300
View File
@@ -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 };
+110
View File
@@ -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;
}
+93
View File
@@ -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,
};
}
+100
View File
@@ -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",
};
}
+90
View File
@@ -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+/, "");
}
+177
View File
@@ -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,
};
+43
View File
@@ -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;
}
+84
View File
@@ -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 };
}