All checks were successful
CI / bootstrap (push) Successful in 7s
CI / lint (push) Successful in 17s
CI / typecheck (push) Successful in 25s
CI / test (3.12) (push) Successful in 2m48s
CI / test (3.13) (push) Successful in 1m41s
CI / test (3.14) (push) Successful in 1m34s
CI / complexity (push) Successful in 21s
190 lines
No EOL
7.1 KiB
TypeScript
190 lines
No EOL
7.1 KiB
TypeScript
import { spawnSync } from "child_process"
|
|
|
|
type GhResult = { status: number | null; stdout: string; stderr: string }
|
|
|
|
/**
|
|
* Build the `--repo <owner/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<GhResult> {
|
|
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<GhResult> {
|
|
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<GhResult> {
|
|
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 `⚠️ <toolName> failed (exit <code>): <stderr or
|
|
* stdout>`, 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}`
|
|
} |