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
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:
parent
77143ac6da
commit
93e7843438
6 changed files with 159 additions and 7 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue