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
+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 };
}