330 lines
9.8 KiB
JavaScript
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));
|
|
}
|
|
}
|