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"
|
import { spawnSync } from "child_process"
|
||||||
|
|
||||||
|
type GhResult = { status: number | null; stdout: string; stderr: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `--repo <owner/repo>` argv fragment for `gh`.
|
* Build the `--repo <owner/repo>` argv fragment for `gh`.
|
||||||
*
|
*
|
||||||
|
|
@ -14,18 +16,167 @@ export function parseRepo(repo?: string): string[] {
|
||||||
return []
|
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
|
* Run `gh` with the given subcommand args, optionally targeting an explicit
|
||||||
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
|
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
|
||||||
* (callers pass `context.worktree`). Returns the raw spawnSync result so the
|
* (callers pass `context.worktree`). Returns the raw spawnSync result so the
|
||||||
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
|
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
|
||||||
* `formatResult` for the standard error string.
|
* `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]
|
const fullArgs = [...parseRepo(repo), ...args]
|
||||||
return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd })
|
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.
|
* Standard success/error formatter for GitHub tools.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default tool({
|
||||||
ghArgs.push("--label", args.labels.join(","))
|
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) {
|
if (r.status !== 0) {
|
||||||
return formatResult(r, "gh issue create")
|
return formatResult(r, "gh issue create")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default tool({
|
||||||
body = body + "\n\nCloses #" + args.issue_number
|
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) {
|
if (r.status !== 0) {
|
||||||
return formatResult(r, "gh pr create")
|
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."),
|
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const r = runGh(
|
const r = await runGh(
|
||||||
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
|
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
|
||||||
args.repo,
|
args.repo,
|
||||||
{ cwd: context.worktree },
|
{ cwd: context.worktree },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default tool({
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
|
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) {
|
if (r.status !== 0) {
|
||||||
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
|
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*$/gms, "")
|
||||||
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
|
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
|
||||||
// Strip `export ` keyword on top-level declarations (shared modules).
|
// 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:
|
// Strip type annotations on function params + return type:
|
||||||
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
|
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
|
||||||
// Handles single-line function signatures (used by _shared.ts). Object
|
// 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
|
// Return type may itself be an object literal type (e.g. create-readme.ts
|
||||||
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
|
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
|
||||||
// so match greedily from `):` up to the final ` {` that opens the body.
|
// 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
|
const cleaned = params
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
|
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue