singularity-forge/src/resources/extensions/github-sync/cli.js
2026-05-05 14:46:18 +02:00

330 lines
9.8 KiB
JavaScript

/**
* Thin wrapper around the `gh` CLI.
*
* Every public function returns `GhResult<T>` — never throws.
* Uses `execFileSync` (not `execSync`) for safety.
*/
import { execFileSync } from "node:child_process";
function ok(data) {
return { ok: true, data };
}
function fail(error) {
return { ok: false, error };
}
// ─── gh Availability ────────────────────────────────────────────────────────
let _ghAvailable = null;
export function ghIsAvailable() {
if (_ghAvailable !== null) return _ghAvailable;
try {
execFileSync("gh", ["--version"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
});
_ghAvailable = true;
} catch {
_ghAvailable = false;
}
return _ghAvailable;
}
/** Reset cached availability (for testing). */
export function _resetGhCache() {
_ghAvailable = null;
}
// ─── Rate Limit Check ───────────────────────────────────────────────────────
let _rateLimitCheckedAt = 0;
let _rateLimitOk = true;
const RATE_LIMIT_CHECK_INTERVAL_MS = 300_000; // 5 minutes
export function ghHasRateLimit(cwd) {
const now = Date.now();
if (now - _rateLimitCheckedAt < RATE_LIMIT_CHECK_INTERVAL_MS)
return _rateLimitOk;
_rateLimitCheckedAt = now;
try {
const raw = execFileSync(
"gh",
["api", "rate_limit", "--jq", ".rate.remaining"],
{
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 10_000,
},
).trim();
const remaining = parseInt(raw, 10);
_rateLimitOk = Number.isFinite(remaining) && remaining >= 100;
} catch {
// Can't check — assume OK so we don't silently disable sync
_rateLimitOk = true;
}
return _rateLimitOk;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
const GH_TIMEOUT = 15_000;
const MAX_BODY_LENGTH = 65_000;
function truncateBody(body) {
if (body.length <= MAX_BODY_LENGTH) return body;
return (
body.slice(0, MAX_BODY_LENGTH) +
"\n\n---\n*Body truncated (exceeded 65K characters)*"
);
}
function runGh(args, cwd) {
try {
const stdout = execFileSync("gh", args, {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: GH_TIMEOUT,
}).trim();
return ok(stdout);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return fail(msg);
}
}
function runGhJson(args, cwd) {
const result = runGh(args, cwd);
if (!result.ok) return fail(result.error);
try {
return ok(JSON.parse(result.data));
} catch {
return fail(`Failed to parse JSON: ${result.data}`);
}
}
// ─── Repo Detection ─────────────────────────────────────────────────────────
export function ghDetectRepo(cwd) {
const result = runGh(
["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
cwd,
);
if (!result.ok) return fail(result.error);
const repo = result.data.trim();
if (!repo || !repo.includes("/")) return fail("Could not detect repo");
return ok(repo);
}
export function ghCreateIssue(cwd, opts) {
const args = [
"issue",
"create",
"--repo",
opts.repo,
"--title",
opts.title,
"--body",
truncateBody(opts.body),
];
if (opts.labels?.length) {
args.push("--label", opts.labels.join(","));
}
if (opts.milestone) {
args.push("--milestone", String(opts.milestone));
}
const result = runGh(args, cwd);
if (!result.ok) return fail(result.error);
// gh issue create returns the URL; extract issue number
const match = result.data.match(/\/issues\/(\d+)/);
if (!match) return fail(`Could not parse issue number from: ${result.data}`);
const issueNumber = parseInt(match[1], 10);
// If parent specified, add as sub-issue via GraphQL
if (opts.parentIssue) {
ghAddSubIssue(cwd, opts.repo, opts.parentIssue, issueNumber);
}
return ok(issueNumber);
}
export function ghCloseIssue(cwd, repo, issueNumber, comment) {
if (comment) {
ghAddComment(cwd, repo, issueNumber, comment);
}
const result = runGh(
["issue", "close", String(issueNumber), "--repo", repo],
cwd,
);
if (!result.ok) return fail(result.error);
return ok(undefined);
}
export function ghAddComment(cwd, repo, issueNumber, body) {
const result = runGh(
[
"issue",
"comment",
String(issueNumber),
"--repo",
repo,
"--body",
truncateBody(body),
],
cwd,
);
if (!result.ok) return fail(result.error);
return ok(undefined);
}
// ─── Sub-Issues (GraphQL) ───────────────────────────────────────────────────
function ghAddSubIssue(cwd, repo, parentNumber, childNumber) {
// Get node IDs for both issues
const parentResult = runGhJson(
["api", `repos/${repo}/issues/${parentNumber}`, "--jq", "{id: .node_id}"],
cwd,
);
const childResult = runGhJson(
["api", `repos/${repo}/issues/${childNumber}`, "--jq", "{id: .node_id}"],
cwd,
);
if (!parentResult.ok || !childResult.ok) {
return fail("Could not resolve issue node IDs for sub-issue linking");
}
const mutation = `mutation { addSubIssue(input: { issueId: "${parentResult.data.id}", subIssueId: "${childResult.data.id}" }) { issue { id } } }`;
return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd);
}
// ─── Milestones ─────────────────────────────────────────────────────────────
export function ghCreateMilestone(cwd, repo, title, description) {
const result = runGhJson(
[
"api",
`repos/${repo}/milestones`,
"-X",
"POST",
"-f",
`title=${title}`,
"-f",
`description=${truncateBody(description)}`,
"-f",
"state=open",
"--jq",
"{number: .number}",
],
cwd,
);
if (!result.ok) return fail(result.error);
return ok(result.data.number);
}
export function ghCloseMilestone(cwd, repo, milestoneNumber) {
const result = runGh(
[
"api",
`repos/${repo}/milestones/${milestoneNumber}`,
"-X",
"PATCH",
"-f",
"state=closed",
],
cwd,
);
if (!result.ok) return fail(result.error);
return ok(undefined);
}
export function ghCreatePR(cwd, opts) {
const args = [
"pr",
"create",
"--repo",
opts.repo,
"--base",
opts.base,
"--head",
opts.head,
"--title",
opts.title,
"--body",
truncateBody(opts.body),
];
if (opts.draft) args.push("--draft");
const result = runGh(args, cwd);
if (!result.ok) return fail(result.error);
const match = result.data.match(/\/pull\/(\d+)/);
if (!match) return fail(`Could not parse PR number from: ${result.data}`);
return ok(parseInt(match[1], 10));
}
export function ghMarkPRReady(cwd, repo, prNumber) {
const result = runGh(["pr", "ready", String(prNumber), "--repo", repo], cwd);
if (!result.ok) return fail(result.error);
return ok(undefined);
}
export function ghMergePR(cwd, repo, prNumber, strategy = "squash") {
const args = [
"pr",
"merge",
String(prNumber),
"--repo",
repo,
strategy === "squash" ? "--squash" : "--merge",
"--delete-branch",
];
const result = runGh(args, cwd);
if (!result.ok) return fail(result.error);
return ok(undefined);
}
// ─── Projects v2 ────────────────────────────────────────────────────────────
export function ghAddToProject(cwd, repo, projectNumber, issueNumber) {
// Get the issue's node ID first
const issueResult = runGhJson(
["api", `repos/${repo}/issues/${issueNumber}`, "--jq", "{id: .node_id}"],
cwd,
);
if (!issueResult.ok) return fail(issueResult.error);
// Get the project's node ID
const [owner] = repo.split("/");
const projectResult = runGhJson(
[
"api",
"graphql",
"-f",
`query=query { user(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`,
"--jq",
".data.user.projectV2.id",
],
cwd,
);
// Try org if user fails
let projectId;
if (projectResult.ok && projectResult.data?.id) {
projectId = projectResult.data.id;
} else {
const orgResult = runGhJson(
[
"api",
"graphql",
"-f",
`query=query { organization(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`,
"--jq",
".data.organization.projectV2.id",
],
cwd,
);
if (orgResult.ok) projectId = orgResult.data?.id;
}
if (!projectId) return fail("Could not find project");
const mutation = `mutation { addProjectV2ItemById(input: { projectId: "${projectId}", contentId: "${issueResult.data.id}" }) { item { id } } }`;
return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd);
}
// ─── Branch Operations ──────────────────────────────────────────────────────
export function ghPushBranch(cwd, branch, setUpstream = true) {
const args = ["git", "push"];
if (setUpstream) args.push("-u", "origin", branch);
else args.push("origin", branch);
try {
execFileSync(args[0], args.slice(1), {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
});
return ok(undefined);
} catch (err) {
return fail(err instanceof Error ? err.message : String(err));
}
}
export function ghCreateBranch(cwd, branch, from) {
try {
execFileSync("git", ["branch", branch, from], {
cwd,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 10_000,
});
return ok(undefined);
} catch (err) {
return fail(err instanceof Error ? err.message : String(err));
}
}