feat(tools): add Forgejo backend with gh CLI fallback
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

This commit is contained in:
opencode-agent 2026-08-06 15:02:20 +00:00
parent 77143ac6da
commit 93e7843438
6 changed files with 159 additions and 7 deletions

View file

@ -1,5 +1,7 @@
import { spawnSync } from "child_process"
type GhResult = { status: number | null; stdout: string; stderr: string }
/**
* Build the `--repo <owner/repo>` argv fragment for `gh`.
*
@ -14,18 +16,167 @@ export function parseRepo(repo?: string): string[] {
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 function runGh(args: string[], repo?: string, opts?: { cwd?: string }) {
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.
*

View file

@ -67,7 +67,7 @@ export default tool({
ghArgs.push("--label", args.labels.join(","))
}
const r = runGh(ghArgs, args.repo, { cwd: context.worktree })
const r = await runGh(ghArgs, args.repo, { cwd: context.worktree })
if (r.status !== 0) {
return formatResult(r, "gh issue create")
}

View file

@ -53,7 +53,7 @@ export default tool({
body = body + "\n\nCloses #" + args.issue_number
}
const r = runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
const r = await runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
if (r.status !== 0) {
return formatResult(r, "gh pr create")
}

View file

@ -8,7 +8,7 @@ export default tool({
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
},
async execute(args, context) {
const r = runGh(
const r = await runGh(
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
args.repo,
{ cwd: context.worktree },

View file

@ -14,7 +14,7 @@ export default tool({
},
async execute(args, context) {
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
const r = runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
const r = await runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
if (r.status !== 0) {
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
}

View file

@ -108,7 +108,8 @@ function stripTs(src) {
out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "")
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
// Strip `export ` keyword on top-level declarations (shared modules).
out = out.replace(/^export\s+(function|const|let|var)\b/gm, "$1")
// Supports `export async function` (async helpers added for Forgejo dispatch).
out = out.replace(/^export\s+(async\s+)?(function|const|let|var)\b/gm, "$1$2")
// Strip type annotations on function params + return type:
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
// Handles single-line function signatures (used by _shared.ts). Object
@ -116,7 +117,7 @@ function stripTs(src) {
// Return type may itself be an object literal type (e.g. create-readme.ts
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
// so match greedily from `):` up to the final ` {` that opens the body.
out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
out = out.replace(/^(\s*(?:async\s+)?function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
const cleaned = params
.split(",")
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())