import { spawnSync } from "child_process" type GhResult = { status: number | null; stdout: string; stderr: string } /** * Build the `--repo ` argv fragment for `gh`. * * If `repo` is provided, returns `["--repo", repo]` (explicit target). * If omitted, returns `[]` so `gh` auto-detects the repo from its `cwd` * (set to `context.worktree` by `runGh`). Auto-detect is the default and * backward-compatible behaviour for all 4 GitHub tools (create-issue, * create-pr, post-review, merge-pr) — see ADR-027. */ export function parseRepo(repo?: string): string[] { if (repo) return ["--repo", repo] return [] } /** * Resolve `owner/repo` for the Forgejo API path. * * `repo` is the explicit owner/name (the same string gh would receive via * `--repo`). When `repo` is omitted, infer it from the `origin` git remote * of the worktree at `opts.cwd` — this mirrors how `gh` auto-detects the * repo from cwd in GitHub mode. Returns `owner/name` or null if the remote * can't be parsed (caller surfaces an error). */ function resolveForgejoRepo(repo: string | undefined, opts?: { cwd?: string }): string | null { if (repo) return repo const cwd = opts?.cwd if (!cwd) return null const r = spawnSync("git", ["-C", cwd, "config", "--get", "remote.origin.url"], { encoding: "utf-8", }) if (r.status !== 0) return null const url = r.stdout.trim() const m = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) return m ? `${m[1]}/${m[2]}` : null } /** * Forgejo REST API helper. Returns a spawnSync-shaped result so the caller's * `formatResult` / status-check code works unchanged. `okStatus` is the HTTP * status treated as success (200 for GET/POST-create, 204 for merge). Non-2xx * is reported as a non-zero `status` with the response body in `stderr`. */ async function callForgejo( method: string, path: string, body: unknown, opts: { okStatus?: number; cwd?: string }, ): Promise { const base = process.env.FORGEJO_URL const token = process.env.FORGEJO_TOKEN const okStatus = opts.okStatus ?? 200 const init: RequestInit = { method, headers: { Authorization: `token ${token}`, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, } if (body !== undefined) init.body = JSON.stringify(body) try { const res = await fetch(`${base}/api/v1${path}`, init) const text = await res.text() if (res.status === okStatus || (okStatus === 200 && res.status >= 200 && res.status < 300)) { return { status: 0, stdout: text, stderr: "" } } return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} → HTTP ${res.status}: ${text}` } } catch (e) { return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} failed: ${String(e)}` } } } /** * Run `gh` with the given subcommand args, optionally targeting an explicit * repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd` * (callers pass `context.worktree`). Returns the raw spawnSync result so the * caller can inspect `status`/`stdout`/`stderr` directly, or pass it to * `formatResult` for the standard error string. * * Dispatch (ADR-forgejo): when `FORGEJO_URL` is set, route to the Forgejo REST * API via `callForgejo` (translating the gh argv to the equivalent API call) * instead of spawning `gh`. GitHub users (no `FORGEJO_URL`) see byte-identical * behaviour — the `gh` path is untouched. The Forgejo result is shaped like a * spawnSync result (`{status, stdout, stderr}`) so callers don't branch. */ export async function runGh(args: string[], repo?: string, opts?: { cwd?: string }): Promise { if (process.env.FORGEJO_URL) { return callForgejoGh(args, repo, opts) } const fullArgs = [...parseRepo(repo), ...args] return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd }) } /** * Translate the supported gh argv shapes to Forgejo API calls. Only the * commands used by the 4 GitHub tools are dispatched (pr merge, pr create, * issue create, pr comment); any other argv falls back to a non-zero * "unsupported in Forgejo mode" error so the dispatch is explicit. */ async function callForgejoGh( args: string[], repo: string | undefined, opts?: { cwd?: string }, ): Promise { const full = repo ?? resolveForgejoRepo(repo, opts) if (!full) { return { status: 1, stdout: "", stderr: "Forgejo mode requires owner/repo — none provided and origin remote not parseable", } } if (args[0] === "pr" && args[1] === "merge") { const n = args[2] return callForgejo( "POST", `/repos/${full}/pulls/${n}/merge`, { Do: "squash", delete_branch_after_merge: true }, { okStatus: 200, cwd: opts?.cwd }, ) } if (args[0] === "pr" && args[1] === "create") { const titleIdx = args.indexOf("--title") const bodyIdx = args.indexOf("--body") const title = titleIdx >= 0 ? args[titleIdx + 1] : "" const body = bodyIdx >= 0 ? args[bodyIdx + 1] : "" const headIdx = args.indexOf("--head") const baseIdx = args.indexOf("--base") const head = headIdx >= 0 ? args[headIdx + 1] : undefined const base = baseIdx >= 0 ? args[baseIdx + 1] : undefined const r = await callForgejo( "POST", `/repos/${full}/pulls`, { title, body, ...(head ? { head } : {}), ...(base ? { base } : {}) }, { cwd: opts?.cwd }, ) if (r.status !== 0) return r const pr = JSON.parse(r.stdout) return { status: 0, stdout: pr.html_url + "\n", stderr: "" } } if (args[0] === "pr" && args[1] === "comment") { const n = args[2] const bodyIdx = args.indexOf("--body") const body = bodyIdx >= 0 ? args[bodyIdx + 1] : "" return callForgejo( "POST", `/repos/${full}/issues/${n}/comments`, { body }, { okStatus: 201, cwd: opts?.cwd }, ) } if (args[0] === "issue" && args[1] === "create") { const titleIdx = args.indexOf("--title") const bodyIdx = args.indexOf("--body") const labelIdx = args.indexOf("--label") const title = titleIdx >= 0 ? args[titleIdx + 1] : "" const body = bodyIdx >= 0 ? args[bodyIdx + 1] : "" const labels = labelIdx >= 0 ? args[labelIdx + 1].split(",") : [] const r = await callForgejo( "POST", `/repos/${full}/issues`, { title, body, labels }, { okStatus: 201, cwd: opts?.cwd }, ) if (r.status !== 0) return r const issue = JSON.parse(r.stdout) return { status: 0, stdout: issue.html_url + "\n", stderr: "" } } return { status: 1, stdout: "", stderr: `gh argv ${JSON.stringify(args)} not supported in Forgejo mode`, } } /** * Standard success/error formatter for GitHub tools. * * On success (exit 0) returns `stdout.trim()`. On failure returns the * canonical error string `⚠️ failed (exit ): `, matching the format previously duplicated across 5 tools. */ export function formatResult(r: { status: number | null; stdout: string; stderr: string }, toolName: string): string { if (r.status === 0) return r.stdout.trim() return `⚠️ ${toolName} failed (exit ${r.status}): ${r.stderr || r.stdout}` }