diff --git a/.opencode/tools/_shared.ts b/.opencode/tools/_shared.ts index 6a26641..0815c08 100644 --- a/.opencode/tools/_shared.ts +++ b/.opencode/tools/_shared.ts @@ -1,5 +1,7 @@ import { spawnSync } from "child_process" +type GhResult = { status: number | null; stdout: string; stderr: string } + /** * Build the `--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 { + 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 { + 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. * diff --git a/.opencode/tools/create-issue.ts b/.opencode/tools/create-issue.ts index e08fef1..f011888 100644 --- a/.opencode/tools/create-issue.ts +++ b/.opencode/tools/create-issue.ts @@ -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") } diff --git a/.opencode/tools/create-pr.ts b/.opencode/tools/create-pr.ts index 292931a..c332e81 100644 --- a/.opencode/tools/create-pr.ts +++ b/.opencode/tools/create-pr.ts @@ -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") } diff --git a/.opencode/tools/merge-pr.ts b/.opencode/tools/merge-pr.ts index 5aefcb3..8dd0a57 100644 --- a/.opencode/tools/merge-pr.ts +++ b/.opencode/tools/merge-pr.ts @@ -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 }, diff --git a/.opencode/tools/post-review.ts b/.opencode/tools/post-review.ts index ebb906e..e28c96d 100644 --- a/.opencode/tools/post-review.ts +++ b/.opencode/tools/post-review.ts @@ -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}` } diff --git a/tests/_ts_loader.mjs b/tests/_ts_loader.mjs index b6f15a8..bc634d7 100644 --- a/tests/_ts_loader.mjs +++ b/tests/_ts_loader.mjs @@ -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())