From 0a01c3ae912685609c1e2df349c35c8dc09d8f8f Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 19:14:03 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20reusable=20?= =?UTF-8?q?workflow=20("subscribe")=20+=20dogfood=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes the ~90-line consumer stub into a reusable Gitea workflow so a repo can subscribe to Gadfly with a tiny caller. Feasibility was probe- verified on this act_runner: workflow_call runs, secrets: inherit delivers, and a fully-qualified owner/repo/path@ref resolves. - .gitea/workflows/review-reusable.yml: `on: workflow_call` job holding the image pin + all env plumbing. Inputs (models/specialists/provider/ concurrency/timeouts/allowed_users/…) default to "" so an empty value falls back to the image's own default — caller overrides only what it wants. Secrets via `secrets: inherit` (optional ones resolve empty). - adversarial-review.yml: gadfly's own dogfood is now a thin CALLER of the reusable (proves it end-to-end; advisory so safe to dogfood). - examples/reusable.yml: the slim ~8-line consumer stub. - README / examples/README / CLAUDE.md document the subscribe path. Caveat: consumers with arbitrary GADFLY_ENDPOINT_s still need the full stub (a reusable workflow can't enumerate dynamic secret names). YAML validated; Go unchanged (build + test green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/adversarial-review.yml | 73 ++++++--------------- .gitea/workflows/review-reusable.yml | 84 +++++++++++++++++++++++++ CLAUDE.md | 3 + README.md | 9 ++- examples/README.md | 3 +- examples/reusable.yml | 49 +++++++++++++++ 6 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 .gitea/workflows/review-reusable.yml create mode 100644 examples/reusable.yml diff --git a/.gitea/workflows/adversarial-review.yml b/.gitea/workflows/adversarial-review.yml index 7c97f74..4c1ea7a 100644 --- a/.gitea/workflows/adversarial-review.yml +++ b/.gitea/workflows/adversarial-review.yml @@ -1,11 +1,10 @@ -# Gadfly — agentic adversarial PR reviewer (https://gitea.stevedudenhoeffer.com/steve/gadfly). +# Gadfly reviewing its OWN PRs — now a thin CALLER of the reusable workflow +# (.gitea/workflows/review-reusable.yml), dogfooding the Phase-4 "subscribe" +# path. The reusable holds the image pin + env plumbing; this file holds only +# the triggers, the actor gate, and gadfly's specific swarm config. # -# Runs the published Gadfly image (pinned to an immutable :sha- tag — act_runner -# caches :latest, and this build is what carries foreman provider-type support) -# as a specialist swarm and posts -# ONE consolidated review comment as gitea-actions. Advisory only — never blocks a -# merge. Gadfly reviewing its OWN PRs — dogfooding: 6 cloud models + the Claude -# Code engine (sonnet) as a competitor. Local Macs and weak cloud models dropped. +# Advisory only — never blocks a merge. Fleet: 6 cloud + Claude Code +# (sonnet, opus, opus:max) competitors. name: Adversarial Review (Gadfly) @@ -33,57 +32,21 @@ jobs: review: # Security: only trusted users may trigger a secret-bearing run via a PR # comment (pull_request + workflow_dispatch are already trusted). Mirrors - # GADFLY_ALLOWED_USERS, the in-container belt-and-suspenders check. + # the allowed_users input below, the in-container belt-and-suspenders check. if: >- github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.actor == 'steve' || github.actor == 'fizi' || github.actor == 'dazed')) - runs-on: ubuntu-latest - # Fleet (6 cloud + claude-code/sonnet, all concurrent) reviewing every PR - # with the 3-lens suite. All cloud now, so runs are fast. - timeout-minutes: 90 - steps: - - uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-c342bdb - env: - GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} - # Claude Code engine auth (for the claude-code/* entries in GADFLY_MODELS - # below): Pro/Max subscription token. Dogfoods the Phase-1 engine on - # gadfly's own PRs as a competitor alongside the Ollama models. - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # Fleet: 6 cloud (3 at a time) + Claude Code (sonnet, opus, and opus - # with max extended thinking) — one - # consolidated comment each, all cloud now. The local Macs (m1/m5) and - # the weaker cloud models (gemma4, gpt-oss:120b, kimi-k2.7-code) were - # dropped as low-signal. The claude-code/* entries run the Phase-1 - # engine as competitors in their own lane (need CLAUDE_CODE_OAUTH_TOKEN). - GADFLY_MODELS: "minimax-m3:cloud,glm-5.2:cloud,glm-5.1:cloud,deepseek-v4-pro:cloud,nemotron-3-super:cloud,qwen3-coder:480b-cloud,claude-code/sonnet,claude-code/opus,claude-code/opus:max" - # cloud runs 3 at once; claude-code 2 at a time; both lanes parallel. - GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=3,claude-code=2" - # 3 cloud models x 3 lenses = 9 concurrent ollama-cloud queries (under the 10 budget). - GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3" - # Default => the 3-lens suite (security, correctness, error-handling). - # Set the repo var GADFLY_SPECIALISTS to override (csv / "all" / "auto"). - GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }} - # Per-lens deadline + bounded steps (also bounds the claude-code lane). - GADFLY_TIMEOUT_SECS: "600" - GADFLY_MAX_STEPS: "14" - # Allow-list for the comment trigger (mirrors the job-level if: guard). - GADFLY_ALLOWED_USERS: "steve,fizi,dazed" - # --- findings telemetry: POST runs + findings to the gadfly-reports store --- - # Advisory & off unless GADFLY_FINDINGS_URL is set; failures only log to - # stderr and never affect the review. GADFLY_REPO / GADFLY_PR are derived - # in-container; the URL + token are user-scope secrets. - GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }} - GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }} - # --- 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 }} + uses: ./.gitea/workflows/review-reusable.yml + secrets: inherit + with: + models: "minimax-m3:cloud,glm-5.2:cloud,glm-5.1:cloud,deepseek-v4-pro:cloud,nemotron-3-super:cloud,qwen3-coder:480b-cloud,claude-code/sonnet,claude-code/opus,claude-code/opus:max" + specialists: "security,correctness,error-handling" + provider_concurrency: "ollama-cloud=3,claude-code=2" + provider_lens_concurrency: "ollama-cloud=3" + timeout_secs: "600" + max_steps: "14" + allowed_users: "steve,fizi,dazed" + timeout_minutes: 90 diff --git a/.gitea/workflows/review-reusable.yml b/.gitea/workflows/review-reusable.yml new file mode 100644 index 0000000..9784b68 --- /dev/null +++ b/.gitea/workflows/review-reusable.yml @@ -0,0 +1,84 @@ +# Gadfly — REUSABLE adversarial-review workflow (Gitea `workflow_call`). +# +# Centralizes the ~90-line consumer stub so a repo can subscribe to Gadfly with +# a tiny caller. A consumer workflow does: +# +# jobs: +# review: +# if: ... # actor gate for the comment trigger +# uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main +# secrets: inherit # passes OLLAMA_CLOUD_API_KEY etc. through +# with: { models: "...", allowed_users: "..." } # all optional +# +# Inputs are all optional and default to "" — an empty env value makes the +# image/entrypoint use its own built-in default, so the caller only sets what it +# wants to override. Secrets come via `secrets: inherit` (verified working on +# this Gitea's act_runner); an undefined secret resolves to empty, so optional +# ones (Claude Code token, foreman endpoints, findings store) are harmless when +# the consumer hasn't set them. +# +# Advisory only — never blocks a merge. The image is pinned to an immutable +# :sha- tag here (act_runner caches :latest); bump it per Gadfly release. + +name: Gadfly review (reusable) + +on: + workflow_call: + inputs: + models: { type: string, default: "" } # GADFLY_MODELS (csv) + specialists: { type: string, default: "" } # GADFLY_SPECIALISTS + provider: { type: string, default: "" } # GADFLY_PROVIDER + base_url: { type: string, default: "" } # GADFLY_BASE_URL + provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY + provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY + specialist_suite: { type: string, default: "" } # reserved / future + timeout_secs: { type: string, default: "" } # GADFLY_TIMEOUT_SECS (per lens) + max_steps: { type: string, default: "" } # GADFLY_MAX_STEPS + worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL + allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS + trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE + timeout_minutes: { type: number, default: 30 } # job wall-clock cap + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout_minutes }} + steps: + - uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-c342bdb + env: + # --- event context (from the CALLER's github.*) ------------------- + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + 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 }} + # --- provider auth (via secrets: inherit; empty if consumer unset) - + OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Common named foreman/LAN endpoints (optional). Consumers with other + # GADFLY_ENDPOINT_s need the full stub (examples/), since a + # reusable workflow can't enumerate arbitrary names. + GADFLY_ENDPOINT_M1: ${{ secrets.GADFLY_ENDPOINT_M1 }} + GADFLY_ENDPOINT_M5: ${{ secrets.GADFLY_ENDPOINT_M5 }} + # --- findings telemetry (optional) -------------------------------- + GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }} + GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }} + # --- config (from inputs; empty => image default) ----------------- + GADFLY_MODELS: ${{ inputs.models }} + GADFLY_SPECIALISTS: ${{ inputs.specialists }} + GADFLY_PROVIDER: ${{ inputs.provider }} + GADFLY_BASE_URL: ${{ inputs.base_url }} + GADFLY_PROVIDER_CONCURRENCY: ${{ inputs.provider_concurrency }} + GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency }} + GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }} + GADFLY_MAX_STEPS: ${{ inputs.max_steps }} + GADFLY_WORKER_MODEL: ${{ inputs.worker_model }} + GADFLY_ALLOWED_USERS: ${{ inputs.allowed_users }} + GADFLY_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} diff --git a/CLAUDE.md b/CLAUDE.md index b689971..42b887d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,9 @@ entrypoint.sh container brains: trigger gating, PR clone, model loop (t used to live in workflow YAML) Dockerfile multi-stage; private-module creds via BuildKit secrets never reach the final image .gitea/workflows/build-image.yml push main → :latest; tag v* → :+:latest; PR → build-only +.gitea/workflows/review-reusable.yml reusable (workflow_call) review job; consumers subscribe with + an ~8-line caller + `secrets: inherit` (Phase 4). gadfly's own + adversarial-review.yml is a thin caller of it (dogfoods the path). examples/ copy-paste consumer stub workflows for different providers ``` diff --git a/README.md b/README.md index 348c222..e8dc2c6 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,12 @@ Gadfly ships as a container image, so consuming repos don't build anything — t it. Drop one file in your repo and set a couple of secrets/vars: 1. Copy a stub from [`examples/`](examples/) to `.gitea/workflows/adversarial-review.yml` in - your repo — [`adversarial-review.yml`](examples/adversarial-review.yml) for the Ollama Cloud - default, or a provider-specific one (local Ollama, OpenAI-compatible, endpoint aliases). See - the [examples index](examples/README.md). + your repo. Two flavors: the slim [`reusable.yml`](examples/reusable.yml) — a tiny caller of + Gadfly's **reusable workflow** (`uses: steve/gadfly/.gitea/workflows/review-reusable.yml@…` + + `secrets: inherit`), best when you take the defaults — or the full self-contained + [`adversarial-review.yml`](examples/adversarial-review.yml) (Ollama Cloud default, with inline + notes for every provider / local Ollama / OpenAI-compatible / endpoint aliases). See the + [examples index](examples/README.md). 2. Add repo config: - **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty ⇒ Gadfly posts a harmless "not configured" notice instead of reviewing). *Not needed if diff --git a/examples/README.md b/examples/README.md index 55589d5..d5d41a5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,8 @@ set the secrets/vars it references. Gadfly is advisory only — it never blocks | File | Backend | Needs | |------|---------|-------| -| [`adversarial-review.yml`](adversarial-review.yml) | **Ollama Cloud** (default) + inline notes for every provider | secret `OLLAMA_CLOUD_API_KEY` | +| [`reusable.yml`](reusable.yml) | **slimmest stub** — calls Gadfly's reusable workflow (`secrets: inherit`); take the defaults or override a few inputs | secret `OLLAMA_CLOUD_API_KEY` | +| [`adversarial-review.yml`](adversarial-review.yml) | **Ollama Cloud** (default) + inline notes for every provider; full self-contained stub | secret `OLLAMA_CLOUD_API_KEY` | | [`local-ollama.yml`](local-ollama.yml) | a **local/LAN Ollama** daemon | nothing (or `GADFLY_BASE_URL` for a remote host) | | [`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_` | diff --git a/examples/reusable.yml b/examples/reusable.yml new file mode 100644 index 0000000..0dc66e3 --- /dev/null +++ b/examples/reusable.yml @@ -0,0 +1,49 @@ +# Gadfly — SLIM consumer stub via the reusable workflow. +# Copy to .gitea/workflows/adversarial-review.yml in your repo. +# +# This is the shortest way to subscribe: it calls Gadfly's centralized reusable +# workflow, which holds the image pin + all the env plumbing. You only declare +# the triggers, the comment-trigger actor gate, and any overrides you want. +# +# Needs: secret OLLAMA_CLOUD_API_KEY (the default Ollama Cloud provider). +# `secrets: inherit` passes your repo/org/user secrets through to the reusable +# workflow (GITEA_TOKEN is automatic). Pin @ to a Gadfly tag/branch. +# +# Prefer this when you're happy with the defaults. For custom named endpoints +# (GADFLY_ENDPOINT_) or a provider the reusable doesn't map, use the full +# stub in adversarial-review.yml instead. + +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: + # Only let your maintainers re-trigger via a PR comment (keep in sync with + # the allowed_users override below). + if: >- + github.event_name != 'issue_comment' + || github.actor == 'your-username' + uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main + secrets: inherit + with: + # All optional — omit to take Gadfly's defaults. Examples: + # models: "qwen3-coder:480b-cloud,gpt-oss:120b-cloud" + # specialists: "security,correctness,error-handling" + allowed_users: "your-username" -- 2.52.0 From 27aa92a6e01e55d9567e7bfdd2b95430fce106d4 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 19:41:45 -0400 Subject: [PATCH 2/2] fix: fold in PR #8 review findings (reusable workflow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm reviewed PR #8 *through the reusable path itself* — proving github.event context propagates into a workflow_call reusable workflow on this act_runner (the one part the probes hadn't covered). Folded in the warranted findings: - review-reusable.yml: bump timeout_minutes default 30 -> 45 (a multi- model/slow-lens review can exceed 30); map the generic GADFLY_API_KEY secret (was missing); add an explicit permissions block; drop the dead `specialist_suite` input. - examples/reusable.yml: actor gate now also requires github.event.issue.pull_request (so an issue-comment on a plain issue doesn't waste a runner), and a note to pin @ to a release tag. Graded ~70 findings (heavy clustering): the real ones above + several by-design/documented (inputs replace vars-overrides; only M1/M5 named endpoints mapped) and many false positives (IS_DRAFT pattern, GITEA_TOKEN via inherit, "empty specialists" misread — empty does default). YAML validated; Go unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/review-reusable.yml | 13 +++++++++++-- examples/reusable.yml | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/review-reusable.yml b/.gitea/workflows/review-reusable.yml index 9784b68..470b519 100644 --- a/.gitea/workflows/review-reusable.yml +++ b/.gitea/workflows/review-reusable.yml @@ -31,13 +31,21 @@ on: base_url: { type: string, default: "" } # GADFLY_BASE_URL provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY - specialist_suite: { type: string, default: "" } # reserved / future timeout_secs: { type: string, default: "" } # GADFLY_TIMEOUT_SECS (per lens) max_steps: { type: string, default: "" } # GADFLY_MAX_STEPS worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE - timeout_minutes: { type: number, default: 30 } # job wall-clock cap + # Job wall-clock cap. 45 > 30 as a default: a multi-model swarm or a slow + # lens (e.g. claude-code with extended thinking) can exceed 30 minutes. + timeout_minutes: { type: number, default: 45 } + +# The reusable job posts the review comment, so it needs issues/PR write. Gitea +# caps these by the caller's granted permissions; declaring them here is explicit. +permissions: + contents: read + issues: write + pull-requests: write jobs: review: @@ -61,6 +69,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Common named foreman/LAN endpoints (optional). Consumers with other # GADFLY_ENDPOINT_s need the full stub (examples/), since a diff --git a/examples/reusable.yml b/examples/reusable.yml index 0dc66e3..fe0b284 100644 --- a/examples/reusable.yml +++ b/examples/reusable.yml @@ -39,7 +39,8 @@ jobs: # the allowed_users override below). if: >- github.event_name != 'issue_comment' - || github.actor == 'your-username' + || (github.event.issue.pull_request && github.actor == 'your-username') + # Pin @ to a Gadfly release tag for stability (@main tracks latest). uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main secrets: inherit with: -- 2.52.0