/** * 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, };