feat(engine): add opencode CLI review engine #26
@@ -53,3 +53,6 @@ jobs:
|
|||||||
# 5-lens suite) from review-reusable.yml. Only the consumer-specific
|
# 5-lens suite) from review-reusable.yml. Only the consumer-specific
|
||||||
# allow-list is set here.
|
# allow-list is set here.
|
||||||
allowed_users: "steve,fizi,dazed"
|
allowed_users: "steve,fizi,dazed"
|
||||||
|
# Gitea >= 1.27 does not propagate dispatch inputs into a called workflow's
|
||||||
|
# github.event — thread the PR number explicitly (empty on non-dispatch events).
|
||||||
|
pr_number: ${{ github.event.inputs.pr_number }}
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: ${{ inputs.timeout_minutes }}
|
timeout-minutes: ${{ inputs.timeout_minutes }}
|
||||||
steps:
|
steps:
|
||||||
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-3095ebf
|
# sha-3095ebf predated the Gitea >= 1.27 workflow_call reclassification
|
||||||
|
# (entrypoint.sh, added in 9d74cb9) — so the dogfood self-review skipped every
|
||||||
|
# PR with "event 'workflow_call' not handled". Bumped to sha-f468fe6 (current
|
||||||
|
# main HEAD) which contains that fix. Bump per Gadfly release.
|
||||||
|
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-f468fe6
|
||||||
env:
|
env:
|
||||||
# --- event context (from the CALLER's github.*) -------------------
|
# --- event context (from the CALLER's github.*) -------------------
|
||||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
@@ -116,6 +120,8 @@ jobs:
|
|||||||
COMMENT_ID: ${{ github.event.comment.id }}
|
COMMENT_ID: ${{ github.event.comment.id }}
|
||||||
ACTOR: ${{ github.actor }}
|
ACTOR: ${{ github.actor }}
|
||||||
# --- provider auth (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
|
# --- provider auth (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
|
||||||
|
# OLLAMA_CLOUD_API_KEY powers both the ollama-cloud majordomo path AND
|
||||||
|
# the opencode engine (GADFLY_MODELS entry "opencode/<model>").
|
||||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ verifies each one against the actual code, and posts its findings as a comment.
|
|||||||
cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout)
|
cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout)
|
||||||
main.go orchestration: fan specialists out (executus/fanout), each a review pass + recheck
|
main.go orchestration: fan specialists out (executus/fanout), each a review pass + recheck
|
||||||
engine.go reviewEngine abstraction: executus run.Executor (majordomo agent loop +
|
engine.go reviewEngine abstraction: executus run.Executor (majordomo agent loop +
|
||||||
compaction/bounding/budget/critic) vs claude-code CLI shell-out
|
compaction/bounding/budget/critic) vs claude-code / opencode CLI shell-outs
|
||||||
|
opencode.go the opencode CLI engine: ollama-cloud model through the OpenCode harness
|
||||||
|
(read-only via a generated OPENCODE_CONFIG_CONTENT agent+provider); for
|
||||||
|
benchmarking the boutique harness vs a free one on the same model
|
||||||
executus.go executus wiring: tool.Registry over the repo tools, the run.Executor build
|
executus.go executus wiring: tool.Registry over the repo tools, the run.Executor build
|
||||||
(compact + model context-limit threshold + per-PR budget + wrap-up critic)
|
(compact + model context-limit threshold + per-PR budget + wrap-up critic)
|
||||||
specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution
|
specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ RUN apk add --no-cache bash git curl jq ca-certificates nodejs npm procps
|
|||||||
# CLI to the image (notably larger); ollama-only users pay the size but nothing
|
# CLI to the image (notably larger); ollama-only users pay the size but nothing
|
||||||
# else. Auth is provided at runtime via CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
|
# else. Auth is provided at runtime via CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
|
||||||
RUN npm install -g @anthropic-ai/claude-code && npm cache clean --force
|
RUN npm install -g @anthropic-ai/claude-code && npm cache clean --force
|
||||||
|
# Bundle the OpenCode CLI (opencode.ai) for the `opencode` review engine
|
||||||
|
# (GADFLY_MODELS=opencode/<model>): a freely-available agentic harness driving an
|
||||||
|
# ollama-cloud model, used to benchmark it against gadfly's own executus harness
|
||||||
|
# on the same model. Auth reuses OLLAMA_CLOUD_API_KEY at runtime. opencode ships a
|
||||||
|
# compiled (Bun) binary; it publishes musl variants (opencode-linux-*-musl) that
|
||||||
|
# npm auto-selects on alpine via the package "libc" field. libstdc++/libgcc are
|
||||||
|
# the Bun binary's runtime deps; gcompat is a belt-and-suspenders fallback in case
|
||||||
|
# npm ever resolves a glibc build here.
|
||||||
|
RUN apk add --no-cache gcompat libstdc++ libgcc \
|
||||||
|
&& npm install -g opencode-ai \
|
||||||
|
&& npm cache clean --force
|
||||||
|
# Best-effort: confirm the binary runs and pre-warm the openai-compatible provider
|
||||||
|
# package into opencode's cache so a review doesn't pay a first-run npm fetch. The
|
||||||
|
# warm-up model call intentionally fails against a dead URL. Never fail the build:
|
||||||
|
# a musl/runtime quirk here must not break the shared image for ollama/claude
|
||||||
|
# users — a broken opencode engine degrades to a normal (advisory) pass error.
|
||||||
|
RUN opencode --version >/dev/null 2>&1 \
|
||||||
|
&& OPENCODE_CONFIG_CONTENT='{"provider":{"gadfly":{"npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://127.0.0.1:9/v1"},"models":{"x":{}}}}}' \
|
||||||
|
timeout 120 opencode run --model gadfly/x "warm" >/dev/null 2>&1 \
|
||||||
|
; true
|
||||||
COPY --from=build /out/gadfly /usr/local/bin/gadfly
|
COPY --from=build /out/gadfly /usr/local/bin/gadfly
|
||||||
COPY scripts /app/scripts
|
COPY scripts /app/scripts
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -140,6 +140,58 @@ as an example, not wired or tested here.
|
|||||||
> specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped
|
> specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped
|
||||||
> with this engine (Claude Code does its own legwork).
|
> with this engine (Claude Code does its own legwork).
|
||||||
|
|
||||||
|
### OpenCode engine (`opencode`)
|
||||||
|
|
||||||
|
The same shell-out idea, but with a **freely-available** harness: Gadfly can review through the
|
||||||
|
**[OpenCode](https://opencode.ai) CLI**, which — like Claude Code — brings its own read tools and
|
||||||
|
verifies findings against the checked-out repo, but drives an **ollama-cloud** model. The point is
|
||||||
|
to benchmark gadfly's boutique executus harness against a good open harness *on the same model*:
|
||||||
|
run `ollama-cloud/glm-5.2` (majordomo loop) and `opencode/glm-5.2` (OpenCode) side by side and
|
||||||
|
compare their findings. This is the wired, no-proxy version of the "alternate backends" comparison
|
||||||
|
described above. The CLI is bundled in the image (Node + `opencode-ai`).
|
||||||
|
|
||||||
|
Select it as a model id:
|
||||||
|
|
||||||
|
| Spec | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `opencode/glm-5.2` | serve `glm-5.2` via ollama-cloud through OpenCode |
|
||||||
|
| `open-code/glm-5.2` | accepted alias spelling (`opencode` is canonical) |
|
||||||
|
| `opencode/qwen3-coder:480b-cloud` | model ids are taken **verbatim** — colons are preserved (no `:thinking` suffix here, unlike claude-code) |
|
||||||
|
| `opencode/<provider>/<model>` | escape hatch: pass `<provider>/<model>` straight to OpenCode's own provider registry/auth (e.g. `opencode/anthropic/claude-sonnet-4-6`) |
|
||||||
|
| `opencode` | bare: OpenCode's configured default model |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2" # the benchmark pairing
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth reuses **`OLLAMA_CLOUD_API_KEY`** (the same secret the ollama-cloud path uses; it's mapped to
|
||||||
|
`OLLAMA_API_KEY`, which the generated provider references as `{env:OLLAMA_API_KEY}` — never a literal
|
||||||
|
secret in config). Tuning knobs (all optional):
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `GADFLY_OPENCODE_MODEL` | *(from the spec suffix)* | overrides the model |
|
||||||
|
| `GADFLY_OPENCODE_BASE_URL` | `https://ollama.com/v1` | ollama-cloud endpoint; point at a local Ollama (`http://localhost:11434/v1`) or any OpenAI-compatible server |
|
||||||
|
| `GADFLY_OPENCODE_EXTRA_ARGS` | *(unset)* | extra `opencode run` args, **whitespace-split**, appended before the positional task |
|
||||||
|
| `GADFLY_OPENCODE_BIN` | `opencode` | CLI binary path |
|
||||||
|
|
||||||
|
> **Read-only is enforced through config, not a flag.** OpenCode has no `--append-system-prompt`, so
|
||||||
|
> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with the
|
||||||
|
> mutating and network tools (`edit`/`bash`/`webfetch`/`websearch`/`external_directory`) denied at both
|
||||||
|
> the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`. That env var is the
|
||||||
|
> highest-precedence config source in the container, so it **outranks any `opencode.json` a reviewed
|
||||||
|
> repo ships** — a repo can't re-enable edits on the reviewer. The subprocess runs with a **reduced
|
||||||
|
> environment**: the provider keys OpenCode needs to authenticate (`OLLAMA_API_KEY` for the primary
|
||||||
|
> path, plus `ANTHROPIC_*`/`OPENAI_*`/`GOOGLE_*`/`GEMINI_*` for the `opencode/<provider>/<model>`
|
||||||
|
> pass-through) alongside `PATH`/`HOME`/locale/`OPENCODE_*`/`GADFLY_OPENCODE_*` — but **not** gadfly's
|
||||||
|
> own secrets (the Gitea token, the findings token, or the claude-code subscription token), which the
|
||||||
|
> CLI has no use for.
|
||||||
|
|
||||||
|
> **Newly wired, lightly tested.** Like the claude-code engine, `auto` specialist selection and the
|
||||||
|
> `delegate_investigation` worker are majordomo-only and are skipped here (OpenCode does its own
|
||||||
|
> legwork). Output capture reads OpenCode's default text output, so treat the engine as new and
|
||||||
|
> sanity-check a run before trusting a benchmark.
|
||||||
|
|
||||||
### Endpoint aliases via env vars
|
### Endpoint aliases via env vars
|
||||||
|
|
||||||
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
|
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
|
||||||
@@ -384,6 +436,7 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults):
|
|||||||
| `GADFLY_BASE_URL` | — | override endpoint (OpenAI/Ollama-compatible servers) |
|
| `GADFLY_BASE_URL` | — | override endpoint (OpenAI/Ollama-compatible servers) |
|
||||||
| `GADFLY_API_KEY` | — | provider key; falls back to the provider's standard env |
|
| `GADFLY_API_KEY` | — | provider key; falls back to the provider's standard env |
|
||||||
| `claude-code` model id | — | route a model through the bundled Claude Code CLI (`claude-code` / `claude-code/<model>`); see [Claude Code engine](#claude-code-engine-claude-code) for its `GADFLY_CLAUDE_*` knobs |
|
| `claude-code` model id | — | route a model through the bundled Claude Code CLI (`claude-code` / `claude-code/<model>`); see [Claude Code engine](#claude-code-engine-claude-code) for its `GADFLY_CLAUDE_*` knobs |
|
||||||
|
| `opencode` model id | — | route an ollama-cloud model through the bundled OpenCode CLI (`opencode/<model>`); see [OpenCode engine](#opencode-engine-opencode) for its `GADFLY_OPENCODE_*` knobs |
|
||||||
| `GADFLY_SPECIALISTS` | default suite | csv of lenses, `all`, or `auto` (dynamic selection) |
|
| `GADFLY_SPECIALISTS` | default suite | csv of lenses, `all`, or `auto` (dynamic selection) |
|
||||||
| `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode |
|
| `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode |
|
||||||
| `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
|
| `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ import (
|
|||||||
// the model's text answer. It is the one primitive both review passes use — the
|
// the model's text answer. It is the one primitive both review passes use — the
|
||||||
// draft review and the adversarial recheck — so the rest of the pipeline
|
// draft review and the adversarial recheck — so the rest of the pipeline
|
||||||
// (specialist composition, recheck orchestration, consolidation, emit) is
|
// (specialist composition, recheck orchestration, consolidation, emit) is
|
||||||
// engine-agnostic. Two implementations:
|
// engine-agnostic. Three implementations:
|
||||||
//
|
//
|
||||||
// - majordomoEngine: the original path — a majordomo tool-using agent loop
|
// - majordomoEngine: the original path — a majordomo tool-using agent loop
|
||||||
// (read_file/grep/… over a sandboxed repoFS).
|
// (read_file/grep/… over a sandboxed repoFS).
|
||||||
// - claudeCodeEngine: shells out to the `claude` CLI in print mode, which
|
// - claudeCodeEngine: shells out to the `claude` CLI in print mode, which
|
||||||
// brings its OWN repo tools; gadfly just feeds it the prompt and reads back
|
// brings its OWN repo tools; gadfly just feeds it the prompt and reads back
|
||||||
// the final text.
|
// the final text.
|
||||||
|
// - openCodeEngine (opencode.go): shells out to the `opencode` CLI likewise,
|
||||||
|
// but driving an ollama-cloud model — for benchmarking the two harnesses on
|
||||||
|
// the same model.
|
||||||
//
|
//
|
||||||
// maxSteps is the tool-step budget for engines that have one (majordomo); the
|
// maxSteps is the tool-step budget for engines that have one (majordomo); the
|
||||||
// claude-code engine manages its own loop and ignores it.
|
// shell-out engines manage their own loop and ignore it.
|
||||||
type reviewEngine interface {
|
type reviewEngine interface {
|
||||||
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
|
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
|
||||||
}
|
}
|
||||||
@@ -157,16 +160,7 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
|
|||||||
// Force an extended-thinking budget for this run (a "...:max" spec).
|
// Force an extended-thinking budget for this run (a "...:max" spec).
|
||||||
cmd.Env = append(cmd.Env, "MAX_THINKING_TOKENS="+strconv.Itoa(e.thinkingTokens))
|
cmd.Env = append(cmd.Env, "MAX_THINKING_TOKENS="+strconv.Itoa(e.thinkingTokens))
|
||||||
}
|
}
|
||||||
// Put the CLI and the Node children it spawns in their own process group and
|
killGroupOnCancel(cmd)
|
||||||
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
|
|
||||||
// orphaned claude/node processes behind in the container.
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
cmd.Cancel = func() error {
|
|
||||||
if cmd.Process != nil {
|
|
||||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
@@ -217,13 +211,39 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i
|
|||||||
return "", fmt.Errorf("claude -p produced no parseable output")
|
return "", fmt.Errorf("claude -p produced no parseable output")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// killGroupOnCancel puts cmd in its own process group and, on context cancel,
|
||||||
|
// SIGKILLs the whole group — so a timed-out shell-out CLI (claude/opencode) can't
|
||||||
|
// leave orphaned Node children behind in the container. Call before cmd.Run.
|
||||||
|
func killGroupOnCancel(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEnv returns the current process environment reduced to the variables for
|
||||||
|
// which keep returns true. Shared by the shell-out engines' minimal-env builders
|
||||||
|
// (claudeEnv, openCodeEnv), which differ only in their keep predicate.
|
||||||
|
func filterEnv(keep func(string) bool) []string {
|
||||||
|
var env []string
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
|
||||||
|
env = append(env, kv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
|
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
|
||||||
// the CLI needs (PATH/HOME, its auth tokens, locale, Node/XDG/GADFLY_CLAUDE_*
|
// the CLI needs (PATH/HOME, its auth tokens, locale, Node/XDG/GADFLY_CLAUDE_*
|
||||||
// knobs), deliberately dropping the rest of the runner's secrets — GITEA_TOKEN,
|
// knobs), deliberately dropping the rest of the runner's secrets — GITEA_TOKEN,
|
||||||
// GADFLY_FINDINGS_TOKEN, provider keys — so they never reach the third-party
|
// GADFLY_FINDINGS_TOKEN, provider keys — so they never reach the third-party
|
||||||
// CLI. Defense in depth: the parent already holds them, but the CLI has no need.
|
// CLI. Defense in depth: the parent already holds them, but the CLI has no need.
|
||||||
func claudeEnv() []string {
|
func claudeEnv() []string {
|
||||||
keep := func(k string) bool {
|
return filterEnv(func(k string) bool {
|
||||||
switch k {
|
switch k {
|
||||||
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
|
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
|
||||||
return true
|
return true
|
||||||
@@ -234,14 +254,7 @@ func claudeEnv() []string {
|
|||||||
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
|
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
|
||||||
strings.HasPrefix(k, "NODE_") ||
|
strings.HasPrefix(k, "NODE_") ||
|
||||||
strings.HasPrefix(k, "XDG_")
|
strings.HasPrefix(k, "XDG_")
|
||||||
}
|
})
|
||||||
var env []string
|
|
||||||
for _, kv := range os.Environ() {
|
|
||||||
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
|
|
||||||
env = append(env, kv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateForErr caps CLI error detail so a stderr dump can't bloat the comment,
|
// truncateForErr caps CLI error detail so a stderr dump can't bloat the comment,
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
//
|
//
|
||||||
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
|
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
|
||||||
// alias / failover chain (required). A bare id is
|
// alias / failover chain (required). A bare id is
|
||||||
// prefixed with GADFLY_PROVIDER.
|
// prefixed with GADFLY_PROVIDER. Two prefixes select a
|
||||||
|
// shell-out CLI engine instead of the in-process loop:
|
||||||
|
// "claude-code/<model>" (Claude Code, see engine.go) and
|
||||||
|
// "opencode/<model>" (OpenCode over ollama-cloud, see
|
||||||
|
// opencode.go) — both bring their own repo tools.
|
||||||
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
|
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
|
||||||
// e.g. "ollama" for a local daemon, "openai", …).
|
// e.g. "ollama" for a local daemon, "openai", …).
|
||||||
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
|
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
|
||||||
@@ -147,15 +151,18 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the review engine. The claude-code engine shells out to the
|
// Resolve the review engine. The shell-out engines (claude-code, opencode)
|
||||||
// `claude` CLI (its own repo tools); every other spec is a majordomo model.
|
// bring their OWN repo tools; every other spec is an in-process majordomo
|
||||||
// auto-selection and the delegate worker are majordomo-only — with
|
// model. auto-selection and the delegate worker are majordomo-only — with a
|
||||||
// claude-code they're skipped (Claude Code does its own legwork).
|
// shell-out engine they're skipped (the CLI does its own legwork).
|
||||||
ccSpec := isClaudeCodeSpec(os.Getenv("GADFLY_MODEL"))
|
spec := os.Getenv("GADFLY_MODEL")
|
||||||
var eng reviewEngine
|
var eng reviewEngine
|
||||||
if ccSpec {
|
switch {
|
||||||
eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root)
|
case isClaudeCodeSpec(spec):
|
||||||
} else {
|
eng = newClaudeCodeEngine(spec, fsTools.root)
|
||||||
|
case isOpenCodeSpec(spec):
|
||||||
|
eng = newOpenCodeEngine(spec, fsTools.root)
|
||||||
|
default:
|
||||||
mdl, merr := resolveModel()
|
mdl, merr := resolveModel()
|
||||||
if merr != nil {
|
if merr != nil {
|
||||||
return fmt.Errorf("resolve model: %w", merr)
|
return fmt.Errorf("resolve model: %w", merr)
|
||||||
@@ -187,13 +194,15 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
|
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
|
||||||
// Majordomo-only — the selector is an llm.Model.
|
// Majordomo-only — the selector is an llm.Model, so a shell-out engine
|
||||||
|
// (claude-code, opencode) can't provide one; fall back to the default suite.
|
||||||
if auto {
|
if auto {
|
||||||
if ccSpec {
|
md, ok := eng.(*majordomoEngine)
|
||||||
fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite")
|
if !ok {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: auto-select requires an in-process model engine; using the default suite")
|
||||||
specialists = suiteFromRegistry(registry, defaultSuite)
|
specialists = suiteFromRegistry(registry, defaultSuite)
|
||||||
} else {
|
} else {
|
||||||
selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl)
|
selector, serr := resolveSelectorModel(md.mdl)
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
return fmt.Errorf("resolve selector model: %w", serr)
|
return fmt.Errorf("resolve selector model: %w", serr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in
|
||||||
|
// non-interactive `run` mode. Like the claude-code engine it is a pure shell-out
|
||||||
|
// — OpenCode brings its OWN read tools (read/grep/glob/list) and reads the
|
||||||
|
// checked-out tree, so findings are verified against real code — but it drives an
|
||||||
|
// ollama-cloud model instead of a Claude subscription. The point is to benchmark
|
||||||
|
// gadfly's boutique executus harness against a freely-available agentic harness
|
||||||
|
// on the SAME models (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").
|
||||||
|
//
|
||||||
|
// OpenCode has no --append-system-prompt flag, so the lens system prompt AND the
|
||||||
|
// read-only discipline are delivered through a generated config injected via the
|
||||||
|
// OPENCODE_CONFIG_CONTENT env var (see config): a custom "gadfly" agent whose
|
||||||
|
// prompt is the system prompt with edit/bash denied, plus a "gadfly" provider
|
||||||
|
// pointing at ollama-cloud. OPENCODE_CONFIG_CONTENT is the highest-precedence
|
||||||
|
// config source that matters in the container — it outranks any opencode.json a
|
||||||
|
// reviewed repo might ship — so a repo can't re-enable edits on us.
|
||||||
|
type openCodeEngine struct {
|
||||||
|
bin string // CLI binary (GADFLY_OPENCODE_BIN, default "opencode")
|
||||||
|
providerModel string // ollama model id for the generated "gadfly" provider ("" = none)
|
||||||
|
modelRef string // --model value ("gadfly/<id>", a pass-through "<prov>/<id>", or "" = CLI default)
|
||||||
|
baseURL string // ollama-cloud base URL (GADFLY_OPENCODE_BASE_URL)
|
||||||
|
repoDir string // cwd for the CLI, so its tools read the checked-out tree
|
||||||
|
extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCodeProviderName / openCodeAgentName are the internal names of the provider
|
||||||
|
// and agent gadfly generates in the injected config. "gadfly" won't collide with
|
||||||
|
|
|||||||
|
// OpenCode's models.dev provider registry.
|
||||||
|
const (
|
||||||
|
openCodeProviderName = "gadfly"
|
||||||
|
openCodeAgentName = "gadfly"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
|
||||||
|
const defaultOpenCodeBaseURL = "https://ollama.com/v1"
|
||||||
|
|
||||||
|
// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine:
|
||||||
|
// the bare id "opencode"/"open-code" or an "opencode/<model>" form (both
|
||||||
|
// spellings accepted; "opencode" is canonical).
|
||||||
|
func isOpenCodeSpec(model string) bool {
|
||||||
|
m := strings.TrimSpace(model)
|
||||||
|
for _, p := range []string{"opencode", "open-code"} {
|
||||||
|
if m == p || strings.HasPrefix(m, p+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOpenCodeEngine builds the engine from the GADFLY_MODEL spec and the optional
|
||||||
|
// GADFLY_OPENCODE_* overrides. The part after the FIRST slash is the model, taken
|
||||||
|
// verbatim — no ":"-suffix parsing, because ollama model ids legitimately contain
|
||||||
|
// colons (e.g. "qwen3-coder:480b-cloud"). Three spec forms:
|
||||||
|
//
|
||||||
|
// opencode → bare: no --model, no generated provider (CLI default model)
|
||||||
|
// opencode/<model> → wrap <model> in the generated ollama-cloud "gadfly" provider
|
||||||
|
// opencode/<provider>/<model> → pass-through: --model <provider>/<model>, using OpenCode's
|
||||||
|
// own provider registry/auth (escape hatch, no generated provider)
|
||||||
|
//
|
||||||
|
// GADFLY_OPENCODE_MODEL overrides the model taken from the spec (and is itself run
|
||||||
|
// through the same slash logic). It does not verify the CLI is installed — a
|
||||||
|
// missing binary surfaces as a normal pass error (advisory, never fatal).
|
||||||
|
func newOpenCodeEngine(spec, repoDir string) *openCodeEngine {
|
||||||
|
e := &openCodeEngine{
|
||||||
|
bin: envOr("GADFLY_OPENCODE_BIN", "opencode"),
|
||||||
|
baseURL: envOr("GADFLY_OPENCODE_BASE_URL", defaultOpenCodeBaseURL),
|
||||||
|
repoDir: repoDir,
|
||||||
|
extraArgs: strings.Fields(os.Getenv("GADFLY_OPENCODE_EXTRA_ARGS")),
|
||||||
|
}
|
||||||
|
var after string
|
||||||
|
if _, a, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
|
||||||
|
after = strings.TrimSpace(a)
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("GADFLY_OPENCODE_MODEL")); env != "" {
|
||||||
|
after = env
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case after == "":
|
||||||
|
// bare spec: let OpenCode's configured default model apply.
|
||||||
|
case strings.Contains(after, "/"):
|
||||||
|
// "<provider>/<model>" pass-through to an OpenCode built-in provider.
|
||||||
|
e.modelRef = after
|
||||||
|
default:
|
||||||
|
// A bare model id → serve it via the generated ollama-cloud provider.
|
||||||
|
e.providerModel = after
|
||||||
|
e.modelRef = openCodeProviderName + "/" + after
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// args assembles the `opencode` argv for one pass. Factored out (and pure) so it
|
||||||
|
// can be unit-tested without invoking the CLI. The task is the positional message
|
||||||
|
// and MUST come last; it never begins with '-' (buildTask output starts with "PR
|
||||||
|
// title:"/"Review …"), so no "--" terminator is needed. Note: in `opencode run`,
|
||||||
|
// -p is basic-auth password, NOT the prompt — the message is positional.
|
||||||
|
func (e *openCodeEngine) args(task string) []string {
|
||||||
|
a := []string{"run", "--agent", openCodeAgentName}
|
||||||
|
if e.modelRef != "" {
|
||||||
|
a = append(a, "--model", e.modelRef)
|
||||||
|
}
|
||||||
|
a = append(a, e.extraArgs...)
|
||||||
|
return append(a, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCode config structs — the minimal shape gadfly generates. Marshaled to JSON
|
||||||
|
// and handed to the CLI via OPENCODE_CONFIG_CONTENT.
|
||||||
|
type openCodeConfig struct {
|
||||||
|
Schema string `json:"$schema"`
|
||||||
|
Permission openCodePermission `json:"permission"`
|
||||||
|
Provider map[string]openCodeProvider `json:"provider,omitempty"`
|
||||||
|
Agent map[string]openCodeAgent `json:"agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCodePermission is OpenCode's permission map: tool key → "allow"|"ask"|"deny".
|
||||||
|
type openCodePermission map[string]string
|
||||||
|
|
||||||
|
// denyMutations denies every OpenCode permission that could change the repo, run
|
||||||
|
// commands, or reach the network / the filesystem outside the checked-out tree —
|
||||||
|
// keeping the reviewer strictly read-only. The read/search tools OpenCode gates
|
||||||
|
// separately (read/glob/grep/list/lsp) stay at their default so the agent can
|
||||||
|
// still verify findings against the code. OpenCode's permission keys are
|
||||||
|
// enumerated at https://opencode.ai/docs/agents; a key it doesn't recognize is
|
||||||
|
// simply ignored, so listing extras is safe.
|
||||||
|
func denyMutations() openCodePermission {
|
||||||
|
return openCodePermission{
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny",
|
||||||
|
"websearch": "deny",
|
||||||
|
"external_directory": "deny",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type openCodeProvider struct {
|
||||||
|
NPM string `json:"npm"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Options openCodeProviderOptions `json:"options"`
|
||||||
|
Models map[string]struct{} `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openCodeProviderOptions struct {
|
||||||
|
BaseURL string `json:"baseURL"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
}
|
||||||
|
gitea-actions
commented
🟠 Read-only enforcement denies only edit/bash; other OpenCode permission categories left at CLI default security · flagged by 1 model
🪰 Gadfly · advisory 🟠 **Read-only enforcement denies only edit/bash; other OpenCode permission categories left at CLI default**
_security · flagged by 1 model_
- **`cmd/gadfly/opencode.go:155` — read-only enforcement denies only `edit` and `bash`; other OpenCode permission/tool categories are left at the CLI default.** `openCodePermission` (opencode.go:125-128) hardcodes exactly two fields, and `config()` sets `Edit:"deny", Bash:"deny"` at both the global and agent level (opencode.go:156-167). The claude-code engine achieves read-only via `--permission-mode plan` (engine.go:64,111); the opencode engine's equivalence rests on the assumption that OpenCod…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
|
||||||
|
type openCodeAgent struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Permission openCodePermission `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// config builds the OpenCode config JSON for one pass. The system prompt becomes
|
||||||
|
// the "gadfly" agent's prompt; the mutating/network tools are denied at BOTH the
|
||||||
|
// global and agent level (defense in depth — OpenCode's read tools stay
|
||||||
|
// available). For a bare or pass-through spec the generated ollama-cloud provider
|
||||||
|
// block is omitted. The API key is always the "{env:OLLAMA_API_KEY}" reference,
|
||||||
|
// never a literal secret baked into the config.
|
||||||
|
func (e *openCodeEngine) config(system string) ([]byte, error) {
|
||||||
|
deny := denyMutations()
|
||||||
|
cfg := openCodeConfig{
|
||||||
|
Schema: "https://opencode.ai/config.json",
|
||||||
|
Permission: deny,
|
||||||
|
Agent: map[string]openCodeAgent{
|
||||||
|
openCodeAgentName: {
|
||||||
|
Description: "Gadfly adversarial code-review lens (read-only).",
|
||||||
|
Mode: "primary",
|
||||||
|
Prompt: system,
|
||||||
|
Permission: deny,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if e.providerModel != "" {
|
||||||
|
cfg.Provider = map[string]openCodeProvider{
|
||||||
|
gitea-actions
commented
🟡 runPass duplicates claudeCodeEngine shell-out orchestration; extract shared helper maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **runPass duplicates claudeCodeEngine shell-out orchestration; extract shared helper**
_maintainability · flagged by 1 model_
- **`cmd/gadfly/opencode.go:185` / `cmd/gadfly/engine.go:155`** — The `runPass` implementations for `claudeCodeEngine` and `openCodeEngine` duplicate ~40 lines of identical shell-out orchestration: `Setpgid` + `SIGKILL` process-group cleanup, `bytes.Buffer` stdout/stderr capture, the `ctx.Err()` cancellation guard, and the `truncateForErr` fallback chain. Any fix to timeout handling or orphan-process cleanup must be edited in two places. **Suggested fix:** Extract a shared helper such as `runShe…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
openCodeProviderName: {
|
||||||
|
NPM: "@ai-sdk/openai-compatible",
|
||||||
|
Name: openCodeProviderName,
|
||||||
|
Options: openCodeProviderOptions{
|
||||||
|
BaseURL: e.baseURL,
|
||||||
|
APIKey: "{env:OLLAMA_API_KEY}",
|
||||||
|
},
|
||||||
|
Models: map[string]struct{}{e.providerModel: {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitea-actions
commented
🟡 OPENCODE_CONFIG_CONTENT outranking repo opencode.json is an external CLI property, not enforced in code; repo could re-enable edits if precedence changes maintainability, security · flagged by 1 model
🪰 Gadfly · advisory 🟡 **OPENCODE_CONFIG_CONTENT outranking repo opencode.json is an external CLI property, not enforced in code; repo could re-enable edits if precedence changes**
_maintainability, security · flagged by 1 model_
- **`cmd/gadfly/opencode.go:196` — `OPENCODE_CONFIG_CONTENT` outranking a repo `opencode.json` is an external CLI property, not enforced in code.** The comments (opencode.go:26-28, :194) and README claim `OPENCODE_CONFIG_CONTENT` "outranks any opencode.json a reviewed repo ships," which is the load-bearing security guarantee that a repo can't re-enable edits. That precedence is a property of the external `opencode` CLI, not anything this code enforces — `runPass` only sets the env var (opencode.…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
return json.Marshal(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
|
||||||
|
gitea-actions
commented
🟡 Process-group setup/kill-on-cancel block copy-pasted from claudeCodeEngine.runPass maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Process-group setup/kill-on-cancel block copy-pasted from claudeCodeEngine.runPass**
_maintainability · flagged by 1 model_
- `cmd/gadfly/opencode.go:200-206` copy-pastes the process-group setup/kill logic from `claudeCodeEngine.runPass` (`cmd/gadfly/engine.go:166-172`) verbatim, down to the matching 3-line comment (only "claude" → "opencode" changed in the comment text). Verified by reading both `runPass` implementations. Since both shell-out engines need identical "own process group + SIGKILL the group on cancel" behavior, this is a good candidate for a one-line shared helper, e.g. `killProcessGroupOnCancel(cmd *ex…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
cfg, err := e.config(system)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("opencode config: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, e.bin, e.args(task)...)
|
||||||
|
cmd.Dir = e.repoDir
|
||||||
|
// Inject the review config (system prompt as the agent prompt + read-only
|
||||||
|
// permissions + the ollama-cloud provider) via OPENCODE_CONFIG_CONTENT, which
|
||||||
|
// outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the
|
||||||
|
// captured stdout free of ANSI decoration.
|
||||||
|
cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1")
|
||||||
|
killGroupOnCancel(cmd) // don't orphan the CLI's Node children on a timed-out lens
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
runErr := cmd.Run()
|
||||||
|
|
||||||
|
// A cancelled/timed-out run must surface as an error, never as whatever partial
|
||||||
|
gitea-actions
commented
🟡 Raw opencode CLI stderr/stdout (possibly containing credential/auth error detail) is truncated but surfaced verbatim into the publicly-posted review comment security · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Raw opencode CLI stderr/stdout (possibly containing credential/auth error detail) is truncated but surfaced verbatim into the publicly-posted review comment**
_security · flagged by 1 model_
- **`cmd/gadfly/opencode.go:219`** — `runPass` forwards up to 800 chars (`truncateForErr`, `engine.go:252-258`) of the opencode subprocess's raw `stderr`/`stdout` into the returned error when the CLI fails. Confirmed this error string flows unmodified into the publicly-posted PR comment: `main.go:340` renders it verbatim as `"⚠️ This reviewer failed to complete: %v"`. If the `opencode` CLI or the underlying ollama-cloud HTTP client ever echoes auth-failure detail containing request fragments (e.…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// bytes the CLI flushed before it was killed.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("opencode run %v", ctx.Err())
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
detail := truncateForErr(stderr.String())
|
||||||
|
if detail == "" {
|
||||||
|
detail = truncateForErr(stdout.String())
|
||||||
|
}
|
||||||
|
if detail != "" {
|
||||||
|
return "", fmt.Errorf("opencode run failed: %v: %s", runErr, detail)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("opencode run failed: %v", runErr)
|
||||||
|
}
|
||||||
|
// OpenCode's default (non-JSON) format prints the assistant's final text; trust
|
||||||
|
// it as the review. An empty result on a clean exit is an error, never "".
|
||||||
|
if out := strings.TrimSpace(stdout.String()); out != "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("opencode run returned no output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCodeEnv builds a minimal environment for the `opencode` subprocess. It
|
||||||
|
// forwards what the CLI needs to reach a model provider: OLLAMA_API_KEY for the
|
||||||
|
gitea-actions
commented
🟠 openCodeEnv() strips provider API keys (e.g. ANTHROPIC_API_KEY), breaking the documented opencode// pass-through escape hatch error-handling, maintainability · flagged by 3 models
🪰 Gadfly · advisory 🟠 **openCodeEnv() strips provider API keys (e.g. ANTHROPIC_API_KEY), breaking the documented opencode/<provider>/<model> pass-through escape hatch**
_error-handling, maintainability · flagged by 3 models_
- **Documented `opencode/<provider>/<model>` escape hatch can't actually authenticate for its own example.** README.md:160 documents `opencode/anthropic/claude-sonnet-4-6` as a working pass-through that uses "OpenCode's own provider registry/auth." But `openCodeEnv()` (cmd/gadfly/opencode.go:243-264) only keeps `PATH/HOME/USER/LOGNAME/TMPDIR/LANG/TERM/SHELL/OLLAMA_API_KEY` plus `LC_/OPENCODE_/GADFLY_OPENCODE_/NODE_/XDG_` prefixes — `ANTHROPIC_API_KEY` (and every other provider key) is stripped f…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// generated ollama-cloud provider (the primary opencode/<model> path), PLUS the
|
||||||
|
// standard provider keys — ANTHROPIC_*, OPENAI_*, GOOGLE_*, GEMINI_* — so the
|
||||||
|
// opencode/<provider>/<model> pass-through form can authenticate against
|
||||||
|
// OpenCode's own built-in providers (those keys are otherwise stripped, which
|
||||||
|
// broke the documented escape hatch). It still withholds gadfly's OWN secrets —
|
||||||
|
// GITEA_TOKEN, GADFLY_API_KEY, GADFLY_FINDINGS_TOKEN, and the claude-code
|
||||||
|
gitea-actions
commented
🔴 openCodeEnv drops ANTHROPIC_API_KEY/OPENAI_API_KEY, breaking the documented opencode// pass-through escape hatch (e.g. opencode/anthropic/...) which relies on OpenCode's built-in providers for auth correctness · flagged by 2 models 🪰 Gadfly · advisory 🔴 **openCodeEnv drops ANTHROPIC_API_KEY/OPENAI_API_KEY, breaking the documented opencode/<provider>/<model> pass-through escape hatch (e.g. opencode/anthropic/...) which relies on OpenCode's built-in providers for auth**
_correctness · flagged by 2 models_
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
// subscription token (CLAUDE_CODE_OAUTH_TOKEN, which OpenCode can't use anyway) —
|
||||||
|
// so they never reach the third-party CLI. OPENCODE_CONFIG_CONTENT is never
|
||||||
|
// inherited: runPass sets it, and a duplicate key would be ambiguous (getenv
|
||||||
|
// returns the first occurrence).
|
||||||
|
func openCodeEnv() []string {
|
||||||
|
return filterEnv(func(k string) bool {
|
||||||
|
if k == "OPENCODE_CONFIG_CONTENT" {
|
||||||
|
return false // set explicitly by runPass; never inherit a competing value
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "OLLAMA_API_KEY":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(k, "LC_") ||
|
||||||
|
strings.HasPrefix(k, "OPENCODE_") ||
|
||||||
|
strings.HasPrefix(k, "GADFLY_OPENCODE_") ||
|
||||||
|
strings.HasPrefix(k, "NODE_") ||
|
||||||
|
strings.HasPrefix(k, "XDG_") ||
|
||||||
|
strings.HasPrefix(k, "ANTHROPIC_") ||
|
||||||
|
strings.HasPrefix(k, "OPENAI_") ||
|
||||||
|
strings.HasPrefix(k, "GOOGLE_") ||
|
||||||
|
strings.HasPrefix(k, "GEMINI_")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsOpenCodeSpec(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"opencode": true,
|
||||||
|
"opencode/glm-5.2": true,
|
||||||
|
"open-code/glm-5.2": true, // accepted alias spelling
|
||||||
|
"opencode/qwen3-coder:480b-cloud": true, // colon-bearing model id
|
||||||
|
"opencode/anthropic/claude": true, // pass-through form
|
||||||
|
" opencode ": true, // trimmed
|
||||||
|
"opencode-extra": false, // not the bare id, not a "/" form
|
||||||
|
"qwen3-coder:480b-cloud": false,
|
||||||
|
"claude-code/opus": false,
|
||||||
|
"": false,
|
||||||
|
}
|
||||||
|
for spec, want := range cases {
|
||||||
|
if got := isOpenCodeSpec(spec); got != want {
|
||||||
|
t.Errorf("isOpenCodeSpec(%q) = %v, want %v", spec, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOpenCodeEngineModel(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
||||||
|
|
||||||
|
// "opencode/<model>" → wrapped in the generated "gadfly" provider.
|
||||||
|
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "glm-5.2" || e.modelRef != "gadfly/glm-5.2" {
|
||||||
|
t.Errorf("glm-5.2: providerModel=%q modelRef=%q, want glm-5.2 / gadfly/glm-5.2", e.providerModel, e.modelRef)
|
||||||
|
}
|
||||||
|
// Colon-bearing ollama id is preserved verbatim — NOT split on ":".
|
||||||
|
if e := newOpenCodeEngine("opencode/qwen3-coder:480b-cloud", "/repo"); e.providerModel != "qwen3-coder:480b-cloud" {
|
||||||
|
t.Errorf("colon id: providerModel=%q, want qwen3-coder:480b-cloud (no split)", e.providerModel)
|
||||||
|
}
|
||||||
|
// "open-code/" spelling behaves identically.
|
||||||
|
if e := newOpenCodeEngine("open-code/glm-5.2", "/repo"); e.modelRef != "gadfly/glm-5.2" {
|
||||||
|
t.Errorf("open-code alias: modelRef=%q, want gadfly/glm-5.2", e.modelRef)
|
||||||
|
}
|
||||||
|
// Pass-through "opencode/<provider>/<model>" → no generated provider.
|
||||||
|
if e := newOpenCodeEngine("opencode/anthropic/claude-sonnet-4-6", "/repo"); e.providerModel != "" || e.modelRef != "anthropic/claude-sonnet-4-6" {
|
||||||
|
t.Errorf("pass-through: providerModel=%q modelRef=%q, want '' / anthropic/claude-sonnet-4-6", e.providerModel, e.modelRef)
|
||||||
|
}
|
||||||
|
// Bare spec → no model, no provider (CLI default applies).
|
||||||
|
if e := newOpenCodeEngine("opencode", "/repo"); e.providerModel != "" || e.modelRef != "" {
|
||||||
|
t.Errorf("bare: providerModel=%q modelRef=%q, want both empty", e.providerModel, e.modelRef)
|
||||||
|
}
|
||||||
|
// GADFLY_OPENCODE_MODEL overrides the spec suffix.
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "deepseek-v3")
|
||||||
|
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "deepseek-v3" || e.modelRef != "gadfly/deepseek-v3" {
|
||||||
|
t.Errorf("env override: providerModel=%q modelRef=%q, want deepseek-v3 / gadfly/deepseek-v3", e.providerModel, e.modelRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeEngineDefaults(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_BIN", "")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
|
||||||
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
||||||
|
if e.bin != "opencode" {
|
||||||
|
t.Errorf("bin = %q, want opencode", e.bin)
|
||||||
|
}
|
||||||
|
if e.baseURL != defaultOpenCodeBaseURL {
|
||||||
|
t.Errorf("baseURL = %q, want %q", e.baseURL, defaultOpenCodeBaseURL)
|
||||||
|
}
|
||||||
|
if e.repoDir != "/repo" {
|
||||||
|
t.Errorf("repoDir = %q, want /repo", e.repoDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeArgs(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "--variant reasoning")
|
||||||
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
||||||
|
args := e.args("TASK-PROMPT")
|
||||||
|
|
||||||
|
// "run" is the subcommand and must be first.
|
||||||
|
if len(args) == 0 || args[0] != "run" {
|
||||||
|
t.Fatalf("args[0] = %q, want run (args=%v)", args, args)
|
||||||
|
}
|
||||||
|
if argAfter(args, "--agent") != openCodeAgentName {
|
||||||
|
t.Errorf("--agent = %q, want %q", argAfter(args, "--agent"), openCodeAgentName)
|
||||||
|
}
|
||||||
|
if argAfter(args, "--model") != "gadfly/glm-5.2" {
|
||||||
|
t.Errorf("--model = %q, want gadfly/glm-5.2", argAfter(args, "--model"))
|
||||||
|
}
|
||||||
|
// extra args appended verbatim (split on whitespace).
|
||||||
|
if !strings.Contains(strings.Join(args, " "), "--variant reasoning") {
|
||||||
|
t.Errorf("extra args not appended: %v", args)
|
||||||
|
}
|
||||||
|
// task is the positional message and must be LAST.
|
||||||
|
if args[len(args)-1] != "TASK-PROMPT" {
|
||||||
|
t.Errorf("last arg = %q, want TASK-PROMPT (args=%v)", args[len(args)-1], args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeArgsBareModelOmitsFlag(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
|
||||||
|
e := newOpenCodeEngine("opencode", "/repo")
|
||||||
|
args := e.args("t")
|
||||||
|
if slices.Contains(args, "--model") {
|
||||||
|
t.Errorf("--model should be omitted for a bare opencode spec: %v", args)
|
||||||
|
}
|
||||||
|
if args[len(args)-1] != "t" {
|
||||||
|
t.Errorf("last arg = %q, want t", args[len(args)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeConfig(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
|
||||||
|
|
||||||
|
// Round-trip a system prompt containing quotes and newlines.
|
||||||
|
sys := "Line one with \"quotes\".\nLine two."
|
||||||
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
||||||
|
raw, err := e.config(sys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
var cfg openCodeConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
t.Fatalf("generated config is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent carries the system prompt verbatim and denies edit+bash.
|
||||||
|
ag, ok := cfg.Agent[openCodeAgentName]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("agent %q missing from config", openCodeAgentName)
|
||||||
|
}
|
||||||
|
if ag.Prompt != sys {
|
||||||
|
t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt)
|
||||||
|
}
|
||||||
|
// Mutating/network tools are denied at BOTH the agent and global level (defense
|
||||||
|
// in depth); the read/search tools stay at OpenCode's default.
|
||||||
|
for _, k := range []string{"edit", "bash", "webfetch", "websearch", "external_directory"} {
|
||||||
|
if ag.Permission[k] != "deny" {
|
||||||
|
t.Errorf("agent permission[%q] = %q, want deny", k, ag.Permission[k])
|
||||||
|
}
|
||||||
|
if cfg.Permission[k] != "deny" {
|
||||||
|
t.Errorf("global permission[%q] = %q, want deny", k, cfg.Permission[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider block: correct npm, default baseURL, env-ref apiKey, model in map.
|
||||||
|
prov, ok := cfg.Provider[openCodeProviderName]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider %q missing from config", openCodeProviderName)
|
||||||
|
}
|
||||||
|
if prov.NPM != "@ai-sdk/openai-compatible" {
|
||||||
|
t.Errorf("provider npm = %q, want @ai-sdk/openai-compatible", prov.NPM)
|
||||||
|
}
|
||||||
|
if prov.Options.BaseURL != defaultOpenCodeBaseURL {
|
||||||
|
t.Errorf("provider baseURL = %q, want %q", prov.Options.BaseURL, defaultOpenCodeBaseURL)
|
||||||
|
}
|
||||||
|
if prov.Options.APIKey != "{env:OLLAMA_API_KEY}" {
|
||||||
|
t.Errorf("provider apiKey = %q, want {env:OLLAMA_API_KEY} (never a literal secret)", prov.Options.APIKey)
|
||||||
|
}
|
||||||
|
if _, ok := prov.Models["glm-5.2"]; !ok {
|
||||||
|
t.Errorf("provider models = %v, want it to contain glm-5.2", prov.Models)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GADFLY_OPENCODE_BASE_URL override reaches the provider.
|
||||||
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "http://localhost:11434/v1")
|
||||||
|
e2 := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
||||||
|
raw2, err := e2.config(sys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config override: %v", err)
|
||||||
|
}
|
||||||
|
var cfg2 openCodeConfig
|
||||||
|
if err := json.Unmarshal(raw2, &cfg2); err != nil {
|
||||||
|
t.Fatalf("override config invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg2.Provider[openCodeProviderName].Options.BaseURL; got != "http://localhost:11434/v1" {
|
||||||
|
t.Errorf("override baseURL = %q, want http://localhost:11434/v1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeConfigNoProviderForPassThroughAndBare(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
||||||
|
for _, spec := range []string{"opencode", "opencode/anthropic/claude-sonnet-4-6"} {
|
||||||
|
e := newOpenCodeEngine(spec, "/repo")
|
||||||
|
raw, err := e.config("sys")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config(%q): %v", spec, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "\"provider\"") {
|
||||||
|
t.Errorf("spec %q: config should omit the provider block, got %s", spec, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeEnvFilters(t *testing.T) {
|
||||||
|
t.Setenv("GITEA_TOKEN", "secret-gitea")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "keep-ollama")
|
||||||
|
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
|
||||||
|
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
|
||||||
|
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic") // pass-through provider auth
|
||||||
|
t.Setenv("OPENAI_API_KEY", "keep-openai") // pass-through provider auth
|
||||||
|
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude")
|
||||||
|
t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob")
|
||||||
|
t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit")
|
||||||
|
|
||||||
|
env := openCodeEnv()
|
||||||
|
has := func(k string) bool {
|
||||||
|
for _, kv := range env {
|
||||||
|
if strings.HasPrefix(kv, k+"=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// kept: the ollama key + the standard provider keys the opencode/<provider>/<model>
|
||||||
|
// pass-through form needs + opencode knobs + PATH
|
||||||
|
for _, k := range []string{"OLLAMA_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
|
||||||
|
if !has(k) {
|
||||||
|
t.Errorf("openCodeEnv dropped %s, but it should be kept", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// dropped: gadfly's own secrets + the claude engine's subscription token
|
||||||
|
// (OpenCode's anthropic provider uses ANTHROPIC_API_KEY, not this OAuth token).
|
||||||
|
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} {
|
||||||
|
if has(k) {
|
||||||
|
t.Errorf("openCodeEnv leaked %s into the subprocess env", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OPENCODE_CONFIG_CONTENT must NOT be inherited — runPass sets it, and a
|
||||||
|
// duplicate key would be ambiguous.
|
||||||
|
if has("OPENCODE_CONFIG_CONTENT") {
|
||||||
|
t.Errorf("openCodeEnv inherited OPENCODE_CONFIG_CONTENT; runPass sets it explicitly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubOpenCode writes an executable shell stub that prints body and exits code,
|
||||||
|
// and returns an engine pointed at it.
|
||||||
|
func stubOpenCode(t *testing.T, body string, code int) *openCodeEngine {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/opencode-stub.sh"
|
||||||
|
script := "#!/bin/sh\nprintf '%s' " + shSingleQuote(body) + "\nexit " + itoa(code) + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &openCodeEngine{bin: path, repoDir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeRunPassCleanResult(t *testing.T) {
|
||||||
|
e := stubOpenCode(t, " REVIEW TEXT ", 0)
|
||||||
|
out, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err != nil || out != "REVIEW TEXT" {
|
||||||
|
t.Fatalf("clean result: got (%q, %v), want (REVIEW TEXT, nil)", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeRunPassEmptyIsError(t *testing.T) {
|
||||||
|
e := stubOpenCode(t, " ", 0)
|
||||||
|
out, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("empty output should be an error, got out=%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeRunPassNonZero(t *testing.T) {
|
||||||
|
e := stubOpenCode(t, "fatal: provider auth failed", 1)
|
||||||
|
_, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "opencode run failed") {
|
||||||
|
t.Fatalf("non-zero exit should error with detail, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenCodeRunPassInjectsConfig proves the end-to-end env plumbing: the stub
|
||||||
|
// echoes OPENCODE_CONFIG_CONTENT back, and the emitted JSON must carry the exact
|
||||||
|
// system prompt as the gadfly agent's prompt.
|
||||||
|
func TestOpenCodeRunPassInjectsConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stub := dir + "/opencode-stub.sh"
|
||||||
|
script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\"\n"
|
||||||
|
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := newOpenCodeEngine("opencode/glm-5.2", dir)
|
||||||
|
e.bin = stub
|
||||||
|
|
||||||
|
sys := "SYSTEM-PROMPT-SENTINEL\nwith a second line"
|
||||||
|
out, err := e.runPass(context.Background(), sys, "task", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runPass: %v", err)
|
||||||
|
}
|
||||||
|
var cfg openCodeConfig
|
||||||
|
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
|
||||||
|
t.Fatalf("injected config is not valid JSON: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if got := cfg.Agent[openCodeAgentName].Prompt; got != sys {
|
||||||
|
t.Errorf("injected agent prompt = %q, want the system prompt", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@
|
|||||||
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
|
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
|
||||||
# "claude-code"/"claude-code/<model>"); Pro/Max subscription
|
# "claude-code"/"claude-code/<model>"); Pro/Max subscription
|
||||||
# token from `claude setup-token`. Else ANTHROPIC_API_KEY.
|
# token from `claude setup-token`. Else ANTHROPIC_API_KEY.
|
||||||
|
# OLLAMA_CLOUD_API_KEY also feeds the opencode engine (GADFLY_MODELS entry
|
||||||
|
# "opencode/<model>"): the bundled `opencode` CLI drives
|
||||||
|
# that ollama-cloud model, for benchmarking the two
|
||||||
|
# harnesses on the same model. Tune via GADFLY_OPENCODE_*.
|
||||||
# GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review")
|
# GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review")
|
||||||
# GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger;
|
# GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger;
|
||||||
# empty => fall back to "is a repo collaborator"
|
# empty => fall back to "is a repo collaborator"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ set the secrets/vars it references. Gadfly is advisory only — it never blocks
|
|||||||
| [`openai-compatible.yml`](openai-compatible.yml) | any **OpenAI-compatible** endpoint (local Ollama `/v1`, gateway, vLLM, OpenRouter…) | `GADFLY_BASE_URL` (+ a key for most gateways) |
|
| [`openai-compatible.yml`](openai-compatible.yml) | any **OpenAI-compatible** endpoint (local Ollama `/v1`, gateway, vLLM, OpenRouter…) | `GADFLY_BASE_URL` (+ a key for most gateways) |
|
||||||
| [`endpoint-aliases.yml`](endpoint-aliases.yml) | **several named backends** at once (one comment each) | repo vars `GADFLY_ENDPOINT_<NAME>` |
|
| [`endpoint-aliases.yml`](endpoint-aliases.yml) | **several named backends** at once (one comment each) | repo vars `GADFLY_ENDPOINT_<NAME>` |
|
||||||
| [`claude-code.yml`](claude-code.yml) | the bundled **Claude Code CLI** engine (`claude-code/<model>`) | secret `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) |
|
| [`claude-code.yml`](claude-code.yml) | the bundled **Claude Code CLI** engine (`claude-code/<model>`) | secret `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) |
|
||||||
|
| [`opencode.yml`](opencode.yml) | the bundled **OpenCode CLI** engine (`opencode/<model>`) driving an ollama-cloud model — benchmark it against the majordomo loop on the same model | secret `OLLAMA_CLOUD_API_KEY` |
|
||||||
| [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — |
|
| [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — |
|
||||||
|
|
||||||
Common to all:
|
Common to all:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Gadfly reviewing via the OpenCode CLI engine.
|
||||||
|
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
|
||||||
|
#
|
||||||
|
# Instead of gadfly's own majordomo loop, each lens shells out to the bundled
|
||||||
|
# `opencode` CLI (opencode.ai) inside the checked-out repo — it uses its own read
|
||||||
|
# tools to verify findings — while driving an ollama-cloud model. Gadfly then runs
|
||||||
|
# its usual verdict + recheck + consolidate pipeline.
|
||||||
|
#
|
||||||
|
# Why: benchmark gadfly's boutique harness against a freely-available one ON THE
|
||||||
|
# SAME MODEL. List both entries to get one comment section each and compare:
|
||||||
|
# GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2"
|
||||||
|
#
|
||||||
|
# Auth: reuses the OLLAMA_CLOUD_API_KEY secret (same as the ollama-cloud path) —
|
||||||
|
# no OpenCode-specific credential is needed for the ollama-cloud provider.
|
||||||
|
#
|
||||||
|
# Heads-up: this engine is newly wired and lightly tested — read the README's
|
||||||
|
# "OpenCode engine" note before relying on it.
|
||||||
|
|
||||||
|
name: Adversarial Review (Gadfly)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, ready_for_review]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pr_number: { description: "PR number to review", required: true }
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
# Security: only trusted users may trigger a secret-bearing run via a PR
|
||||||
|
# comment. Replace the username(s) below with your maintainers — keep them in
|
||||||
|
# sync with GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'issue_comment'
|
||||||
|
|| github.actor == 'your-username'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
# --- OpenCode engine ---
|
||||||
|
# Reuses the ollama-cloud key; mapped to OLLAMA_API_KEY in-container and
|
||||||
|
# referenced by the generated provider as {env:OLLAMA_API_KEY}.
|
||||||
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
|
# "opencode/<model>" serves that model via ollama-cloud through OpenCode.
|
||||||
|
# Model ids are verbatim (colons preserved). List an "ollama-cloud/<model>"
|
||||||
|
# entry too to benchmark the two harnesses on the same model.
|
||||||
|
GADFLY_MODELS: "opencode/glm-5.2"
|
||||||
|
# Optional CLI tuning:
|
||||||
|
# GADFLY_OPENCODE_BASE_URL: "https://ollama.com/v1" # or a local Ollama /v1
|
||||||
|
# GADFLY_OPENCODE_MODEL: "glm-5.2" # overrides the spec suffix
|
||||||
|
# GADFLY_OPENCODE_EXTRA_ARGS: "--variant reasoning" # whitespace-split
|
||||||
|
# Escape hatch: "opencode/<provider>/<model>" passes straight to OpenCode's
|
||||||
|
# own provider registry/auth (e.g. opencode/anthropic/claude-sonnet-4-6).
|
||||||
|
GADFLY_ALLOWED_USERS: "your-username"
|
||||||
|
# --- event context (leave as-is) ---
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
||||||
|
PR_BRANCH: ${{ github.head_ref }}
|
||||||
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
|
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||||
|
COMMENT_ID: ${{ github.event.comment.id }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
# tuning are read straight from the inherited environment — same as the other
|
# tuning are read straight from the inherited environment — same as the other
|
||||||
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
|
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
|
||||||
#
|
#
|
||||||
|
# opencode engine: when MODEL is "opencode" or "opencode/<model>" the binary
|
||||||
|
# shells out to the bundled `opencode` CLI, driving an ollama-cloud model (for
|
||||||
|
# benchmarking against the majordomo path on the same model). Its auth reuses
|
||||||
|
# OLLAMA_CLOUD_API_KEY (mapped to OLLAMA_API_KEY below, same as the ollama-cloud
|
||||||
|
# path) and GADFLY_OPENCODE_* tuning is read from the inherited environment — so
|
||||||
|
# no extra wiring is needed here either.
|
||||||
|
#
|
||||||
# Optional:
|
# Optional:
|
||||||
# MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000)
|
# MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000)
|
||||||
# GADFLY_STATUS_FILE per-model JSON path for the live status board (set by
|
# GADFLY_STATUS_FILE per-model JSON path for the live status board (set by
|
||||||
|
|||||||
⚪ const block groups unrelated constants; doc comment visually misattaches to wrong const
maintainability · flagged by 1 model
cmd/gadfly/opencode.go:38-46— const block groups unrelated constants; doc comment visually misattaches to the wrong const. The singleconstblock lumps the paired name constants (openCodeProviderName/openCodeAgentName, both"gadfly") together with the unrelateddefaultOpenCodeBaseURL. BecauseopenCodeAgentName = "gadfly"is shorter than the others, gofmt aligns it under a different column, so the// defaultOpenCodeBaseURL is ollama-cloud's…comment on line 44 reads as if i…🪰 Gadfly · advisory