Compare commits
33 Commits
48936d55b2
...
v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc3c982fa | |||
| 95a9ec546a | |||
| 8f69e71311 | |||
| 0d80ae73d8 | |||
| b02b11d691 | |||
| 20a5c431f2 | |||
| daff6d08a1 | |||
| 18de9b8ebc | |||
| f06fe5ef72 | |||
| 5f86062a5a | |||
| b23eeb8cbf | |||
| a4cdc905c9 | |||
| c342bdb905 | |||
| 80d8f53f63 | |||
| 82f7ef78d5 | |||
| 86f12c126f | |||
| c3d09d3bd4 | |||
| 0ad5b66170 | |||
| d7f364d803 | |||
| d0de034726 | |||
| 6e3a83c437 | |||
| a1e9d109e5 | |||
| b409dff4ed | |||
| 92bf22a1be | |||
| 9e582bfaca | |||
| 49f3623204 | |||
| 4b8f9aa39b | |||
| 7809d1b93d | |||
| 676c9d4f07 | |||
| 04cd260ff9 | |||
| bd76aa8286 | |||
| d9405f4f69 | |||
| 6123604595 |
@@ -0,0 +1,55 @@
|
|||||||
|
# Gadfly reviewing its OWN PRs — a thin CALLER of the reusable workflow
|
||||||
|
# (.gitea/workflows/review-reusable.yml), dogfooding the "subscribe" path. The
|
||||||
|
# reusable holds the image pin, env plumbing, AND the default swarm; this file
|
||||||
|
# holds only the triggers, the actor gate, secret forwarding, and allow-list.
|
||||||
|
#
|
||||||
|
# Advisory only — never blocks a merge. It inherits the default swarm: 3 cloud
|
||||||
|
# models + Claude Code (sonnet, opus, opus:max), 5-lens suite (claude models run
|
||||||
|
# one at a time, each with all 5 lenses at once).
|
||||||
|
|
||||||
|
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 (pull_request + workflow_dispatch are already trusted). Mirrors
|
||||||
|
# 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'))
|
||||||
|
uses: ./.gitea/workflows/review-reusable.yml
|
||||||
|
# Least privilege: forward ONLY the secrets this swarm uses (cloud + Claude
|
||||||
|
# Code + findings telemetry), not `secrets: inherit`. GITEA_TOKEN is auto.
|
||||||
|
secrets:
|
||||||
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
|
||||||
|
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
|
||||||
|
with:
|
||||||
|
# Inherit the default swarm (3 cloud + Claude Code sonnet/opus/opus:max,
|
||||||
|
# 5-lens suite) from review-reusable.yml. Only the consumer-specific
|
||||||
|
# allow-list is set here.
|
||||||
|
allowed_users: "steve,fizi,dazed"
|
||||||
@@ -1,47 +1,127 @@
|
|||||||
name: Build & push image
|
name: Build & push image
|
||||||
|
|
||||||
# Builds the Gadfly reviewer container and pushes it to the Gitea container
|
# Builds the Gadfly reviewer container and pushes it to the Gitea container
|
||||||
# registry. Tag a release (v1, v1.2.0, …) to publish that version + :latest.
|
# registry. Mirrors mort-ci.yml's build-and-push (BuildKit secrets for private
|
||||||
|
# module access + the LAN --add-host so the builder can reach the registry).
|
||||||
|
#
|
||||||
|
# push to main -> :latest + :sha-<short>
|
||||||
|
# push tag v* -> :<tag> + :latest
|
||||||
|
# other branch push -> :branch-<safe> + :sha-<short>
|
||||||
|
# pull_request -> build only (no push), as a sanity check
|
||||||
#
|
#
|
||||||
# Required repo secrets:
|
# Required repo secrets:
|
||||||
# REGISTRY_USER / REGISTRY_PASSWORD Gitea creds with registry push + read
|
# REGISTRY_USER / REGISTRY_PASSWORD Gitea creds with registry push + read
|
||||||
# access to the private majordomo module.
|
# access to the private majordomo module.
|
||||||
|
# Optional:
|
||||||
|
# DISCORD_WEBHOOK_URL build notifications (unset => silent).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
# Docs/example-only changes don't change the image — skip the rebuild.
|
||||||
|
# (Path filters are not applied to tag pushes, so `v*` releases always build.)
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "examples/**"
|
||||||
|
- "LICENSE"
|
||||||
|
- ".gitignore"
|
||||||
|
- ".dockerignore"
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "examples/**"
|
||||||
|
- "LICENSE"
|
||||||
|
- ".gitignore"
|
||||||
|
- ".dockerignore"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: gadfly-image-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea.stevedudenhoeffer.com/steve/gadfly
|
IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
image:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
run: docker buildx create --use --name gadfly-builder --driver docker-container 2>/dev/null || docker buildx use gadfly-builder
|
||||||
|
|
||||||
- name: Log in to the registry
|
- name: Log in to the registry
|
||||||
run: |
|
if: github.event_name != 'pull_request'
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
env:
|
||||||
| docker login gitea.stevedudenhoeffer.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: echo "${REGISTRY_PASSWORD}" | docker login gitea.stevedudenhoeffer.com -u "${REGISTRY_USER}" --password-stdin
|
||||||
|
|
||||||
- name: Resolve tags
|
- name: Compute tags
|
||||||
id: tags
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||||
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
PUSH=true
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
# Build-only sanity check; nothing published.
|
||||||
|
TAGS="${IMAGE_NAME}:pr-${{ github.event.pull_request.number }}"
|
||||||
|
PUSH=false
|
||||||
|
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
TAGS="${IMAGE_NAME}:${GITHUB_REF_NAME},${IMAGE_NAME}:latest"
|
||||||
|
elif [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
TAGS="${IMAGE_NAME}:latest,${IMAGE_NAME}:sha-${SHA_SHORT}"
|
||||||
else
|
else
|
||||||
echo "version=dev-$(echo ${{ github.sha }} | cut -c1-8)" >> "$GITHUB_OUTPUT"
|
BRANCH_SAFE=$(echo "${GITHUB_REF_NAME}" | sed 's/[^a-zA-Z0-9._-]/-/g; s/--*/-/g; s/^-//; s/-$//')
|
||||||
|
TAGS="${IMAGE_NAME}:branch-${BRANCH_SAFE},${IMAGE_NAME}:sha-${SHA_SHORT}"
|
||||||
fi
|
fi
|
||||||
|
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "push=${PUSH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Tags: ${TAGS} (push=${PUSH})"
|
||||||
|
|
||||||
- name: Build & push
|
- name: Notify Discord (started)
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
[ -z "$WEBHOOK_URL" ] && exit 0
|
||||||
--build-arg GIT_USER="${{ secrets.REGISTRY_USER }}" \
|
MSG="🪰 Gadfly image build #${{ github.run_number }} started on \`${{ github.ref_name }}\` (${{ github.sha }})."
|
||||||
--build-arg GIT_TOKEN="${{ secrets.REGISTRY_PASSWORD }}" \
|
curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
|
||||||
-t "${IMAGE}:${{ steps.tags.outputs.version }}" \
|
|
||||||
-t "${IMAGE}:latest" \
|
- name: Build and push
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
TAG_FLAGS=""
|
||||||
|
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
for tag in "${TAG_ARRAY[@]}"; do TAG_FLAGS="$TAG_FLAGS --tag $tag"; done
|
||||||
|
|
||||||
|
PUSH_FLAG="--push"
|
||||||
|
[ "${{ steps.meta.outputs.push }}" = "false" ] && PUSH_FLAG="--output=type=cacheonly"
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
$PUSH_FLAG \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
$TAG_FLAGS \
|
||||||
|
--add-host gitea.stevedudenhoeffer.com:192.168.0.134 \
|
||||||
|
--secret id=REGISTRY_USER,env=REGISTRY_USER \
|
||||||
|
--secret id=REGISTRY_PASSWORD,env=REGISTRY_PASSWORD \
|
||||||
|
--file ./Dockerfile \
|
||||||
.
|
.
|
||||||
docker push "${IMAGE}:${{ steps.tags.outputs.version }}"
|
|
||||||
docker push "${IMAGE}:latest"
|
- name: Notify Discord (result)
|
||||||
|
if: always() && github.event_name != 'pull_request'
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
run: |
|
||||||
|
[ -z "$WEBHOOK_URL" ] && exit 0
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
MSG="✅ Gadfly image build #${{ github.run_number }} succeeded. Tags: \`${{ steps.meta.outputs.tags }}\`."
|
||||||
|
else
|
||||||
|
MSG="❌ Gadfly image build #${{ github.run_number }} failed. Check Actions logs."
|
||||||
|
fi
|
||||||
|
curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 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@v1
|
||||||
|
# secrets: # forward ONLY what the reviewer needs
|
||||||
|
# OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
|
# CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# with: { allowed_users: "..." } # config inputs are optional (see below)
|
||||||
|
#
|
||||||
|
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME from,
|
||||||
|
# in order: a consumer's `with:` input → the owner's user-scope GADFLY_DEFAULT_*
|
||||||
|
# variable → the image's built-in default. Because variables are injected per-run
|
||||||
|
# (not part of this cached file), the owner retunes the whole fleet by editing ONE
|
||||||
|
# variable — see the inputs block and README "Central config via variables".
|
||||||
|
# Secrets are DECLARED below (workflow_call.secrets) so a
|
||||||
|
# caller forwards only the credentials the reviewer actually uses — least
|
||||||
|
# privilege — rather than `secrets: inherit`, which leaks every caller secret
|
||||||
|
# (registry/deploy/db creds) into this workflow. `secrets: inherit` still works
|
||||||
|
# if you accept that exposure; the explicit form is recommended. GITEA_TOKEN is
|
||||||
|
# the automatic job token (no need to forward it).
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate
|
||||||
|
# releases, so central tuning here propagates without per-consumer edits — or a
|
||||||
|
# full `@<sha>` for an immutable pin. Avoid `@main` (moves on every push).
|
||||||
|
|
||||||
|
name: Gadfly review (reusable)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME,
|
||||||
|
# in priority order: a consumer's explicit `with:` input → the owner's
|
||||||
|
# user/org-level variable (GADFLY_DEFAULT_*) → the image's built-in default.
|
||||||
|
# Variables are injected per-run by Gitea (not baked into this file), so the
|
||||||
|
# owner can retune the whole fleet by editing ONE variable — it propagates even
|
||||||
|
# though long-lived act_runners CACHE this workflow file by ref (a moved tag is
|
||||||
|
# NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache).
|
||||||
|
#
|
||||||
|
# Owner-set user-scope variables (see README "Central config via variables"):
|
||||||
|
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
|
||||||
|
# GADFLY_DEFAULT_PROVIDER_CONCURRENCY, GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY,
|
||||||
|
# GADFLY_ENDPOINT_RAGNAROS (the 4090 Ti endpoint).
|
||||||
|
# An unset variable + no input → the image default (one model, default suite),
|
||||||
|
# so a public consumer with neither still gets a sane minimal review.
|
||||||
|
inputs:
|
||||||
|
models: { type: string, default: "" } # GADFLY_MODELS — empty falls back to user var GADFLY_DEFAULT_MODELS, then the image default
|
||||||
|
specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS
|
||||||
|
provider: { type: string, default: "" } # GADFLY_PROVIDER
|
||||||
|
base_url: { type: string, default: "" } # GADFLY_BASE_URL
|
||||||
|
provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_CONCURRENCY
|
||||||
|
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
|
||||||
|
timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens)
|
||||||
|
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
|
||||||
|
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
|
||||||
|
allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS (consumer-specific; set in your stub)
|
||||||
|
trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE
|
||||||
|
# Job wall-clock cap. 90 as a default: the 5-lens suite across a slow lane
|
||||||
|
# (claude-code with extended thinking) over two passes can run long.
|
||||||
|
timeout_minutes: { type: number, default: 90 }
|
||||||
|
# Declared so callers can forward ONLY the secrets the reviewer needs
|
||||||
|
# (least privilege) instead of `secrets: inherit`, which would hand this
|
||||||
|
# workflow every secret in the caller's repo (registry/deploy/db creds the
|
||||||
|
# review never touches). All optional — an unset/unpassed secret resolves to
|
||||||
|
# empty, harmless for the providers a given consumer doesn't use. GITEA_TOKEN
|
||||||
|
# is the automatic job token and need not be declared/forwarded. Named
|
||||||
|
# endpoints (GADFLY_ENDPOINT_*) come from user/org VARS now, not secrets.
|
||||||
|
secrets:
|
||||||
|
OLLAMA_CLOUD_API_KEY: { required: false }
|
||||||
|
OPENAI_API_KEY: { required: false }
|
||||||
|
ANTHROPIC_API_KEY: { required: false }
|
||||||
|
GOOGLE_API_KEY: { required: false }
|
||||||
|
GADFLY_API_KEY: { required: false }
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: { required: false }
|
||||||
|
GADFLY_FINDINGS_URL: { required: false }
|
||||||
|
GADFLY_FINDINGS_TOKEN: { required: false }
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
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 }}
|
||||||
|
# github.token is the auto job token from the github CONTEXT (not a
|
||||||
|
# secret), so it's present even without `secrets: inherit`. Using
|
||||||
|
# secrets.GITEA_TOKEN here would be empty under explicit secret
|
||||||
|
# forwarding, since the auto token isn't a forwarded workflow_call secret.
|
||||||
|
GITEA_TOKEN: ${{ github.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 (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
|
||||||
|
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 }}
|
||||||
|
GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# Named LAN endpoints, defined in user/org vars (format
|
||||||
|
# "<provider>|<base-url>[|<key>]"). Adding a NEW name still needs a line
|
||||||
|
# here — a reusable workflow can't enumerate arbitrary vars.GADFLY_ENDPOINT_*.
|
||||||
|
# NB: vars are NOT masked like secrets — if an endpoint embeds an auth
|
||||||
|
# token, keep that one a secret instead.
|
||||||
|
GADFLY_ENDPOINT_M1: ${{ vars.GADFLY_ENDPOINT_M1 }}
|
||||||
|
GADFLY_ENDPOINT_M5: ${{ vars.GADFLY_ENDPOINT_M5 }}
|
||||||
|
# ragnaros = the 4090 Ti via its llama-swap proxy. Defined in the user
|
||||||
|
# var GADFLY_ENDPOINT_RAGNAROS (format "<provider>|<base-url>[|<key>]")
|
||||||
|
# so the URL can change without editing this file; the matching model is
|
||||||
|
# ragnaros/qwen3.6-27b in GADFLY_DEFAULT_MODELS. NB: use the un-hyphenated
|
||||||
|
# `llamaswap` provider spelling in the var — the pinned image needs it.
|
||||||
|
GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}
|
||||||
|
# --- 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 || vars.GADFLY_DEFAULT_MODELS }}
|
||||||
|
GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }}
|
||||||
|
GADFLY_PROVIDER: ${{ inputs.provider }}
|
||||||
|
GADFLY_BASE_URL: ${{ inputs.base_url }}
|
||||||
|
GADFLY_PROVIDER_CONCURRENCY: ${{ inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY }}
|
||||||
|
GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_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 }}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Gadfly — Developer Guide
|
||||||
|
|
||||||
|
Gadfly (🪰) is an **agentic adversarial code reviewer** that runs in Gitea Actions. On a pull
|
||||||
|
request it reads the *checked-out repository* with read-only tools, hunts for real problems,
|
||||||
|
verifies each one against the actual code, and posts its findings as a comment. It is
|
||||||
|
**advisory only** — it never blocks a merge.
|
||||||
|
|
||||||
|
> This is a public, **vibe-coded** project (built largely by an AI agent). Keep that framing
|
||||||
|
> honest in the README; don't oversell it.
|
||||||
|
|
||||||
|
## Project goals (keep changes aligned to these)
|
||||||
|
|
||||||
|
1. **Find *real* problems, not nits.** The whole point of the agentic tools + two-pass
|
||||||
|
recheck is to kill diff-only false positives. Anything that raises the false-positive rate
|
||||||
|
(or removes verification) works against the project.
|
||||||
|
2. **Advisory, never blocking.** Gadfly must never fail a CI job for review *content*, never
|
||||||
|
merge, never deploy. Non-zero exit only on usage/config errors; even then run.sh posts a
|
||||||
|
notice rather than failing. Do not add it to branch-protection required checks.
|
||||||
|
3. **Easy to turn on for any repo.** Consumers should need only a ~15-line stub workflow + a
|
||||||
|
couple of secrets/vars. All real logic lives in the image (`entrypoint.sh`), not in the
|
||||||
|
consumer's YAML (Gitea's act_runner has weak YAML expression support).
|
||||||
|
4. **Provider-agnostic.** Powered by [majordomo](https://gitea.stevedudenhoeffer.com/steve/majordomo),
|
||||||
|
so it can target Ollama (local/cloud), OpenAI, Anthropic, Google, or any
|
||||||
|
OpenAI/Ollama-compatible endpoint. Don't re-hardcode a single provider.
|
||||||
|
5. **Portable & self-contained.** `cmd/gadfly` depends only on the Go stdlib + majordomo. Keep
|
||||||
|
it that way — no heavyweight deps, no coupling to any one consumer repo (e.g. mort).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout)
|
||||||
|
main.go orchestration: loop specialists, each a review pass + adversarial recheck
|
||||||
|
engine.go reviewEngine abstraction: majordomo agent loop vs claude-code CLI shell-out
|
||||||
|
specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution
|
||||||
|
auto.go dynamic `auto` selection: a selector model picks lenses per-diff (may invent)
|
||||||
|
delegate.go worker-tier delegate_investigation tool (cheap sub-agent does legwork)
|
||||||
|
consolidate.go verdict parsing + one-comment consolidation (a section per specialist)
|
||||||
|
model.go provider/model + selector + worker resolution (majordomo.Parse) + endpoint aliases
|
||||||
|
tools.go the 5 read-only repo tools (read_file/list_dir/grep/find_files/get_diff)
|
||||||
|
recheck.go second-pass verification prompt + verdict recompute
|
||||||
|
*_test.go sandbox, recheck, wrap-up, spec/endpoint-parse, specialist-resolution tests
|
||||||
|
scripts/run.sh fetch PR diff+meta, run the binary, upsert ONE labeled PR comment
|
||||||
|
scripts/status-board.sh render+upsert ONE live status-board comment (per-model/per-lens progress)
|
||||||
|
scripts/system-prompt.txt the reviewer persona + verification discipline (generic, not repo-specific)
|
||||||
|
entrypoint.sh container brains: trigger gating, PR clone, model loop (the logic that
|
||||||
|
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* → :<tag>+:latest; PR → build-only
|
||||||
|
.gitea/workflows/review-reusable.yml reusable (workflow_call) review job; resolves swarm config at
|
||||||
|
RUNTIME: consumer `with:` input → owner user-scope var (GADFLY_DEFAULT_MODELS /
|
||||||
|
_SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, +
|
||||||
|
GADFLY_ENDPOINT_RAGNAROS) → image default. Vars are injected per-run, so editing
|
||||||
|
one var retunes the whole fleet even though long-lived act_runners CACHE this file
|
||||||
|
by ref (a moved tag is NOT re-fetched — only a runtime value or a fresh @<sha>
|
||||||
|
bypasses the cache). Consumers subscribe with an ~8-line caller forwarding only the
|
||||||
|
secrets the reviewer needs and pinned to an immutable @<sha> (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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow:** consumer stub workflow → container `entrypoint.sh` (gate + clone) →
|
||||||
|
`scripts/run.sh` (per model) → `cmd/gadfly` binary (agentic review) → markdown → run.sh
|
||||||
|
upserts a PR comment as `gitea-actions`.
|
||||||
|
|
||||||
|
**Two passes:** a *review* pass drafts findings; an adversarial *recheck* pass independently
|
||||||
|
re-verifies each finding against the code and drops the unconfirmed ones, recomputing the
|
||||||
|
verdict. Verdict is one of: `No material issues found` / `Minor issues` / `Blocking issues found`.
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build ./cmd/gadfly # needs read access to the private majordomo module
|
||||||
|
go test ./...
|
||||||
|
gofmt -l cmd/ # must be clean
|
||||||
|
docker build -t gadfly:dev --secret id=REGISTRY_USER,env=REGISTRY_USER --secret id=REGISTRY_PASSWORD,env=REGISTRY_PASSWORD .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it locally against a real diff without CI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git -C /path/to/repo diff main > /tmp/x.diff
|
||||||
|
GADFLY_PROVIDER=ollama GADFLY_MODEL=qwen2.5-coder:7b \
|
||||||
|
GADFLY_REPO_DIR=/path/to/repo GADFLY_DIFF_FILE=/tmp/x.diff \
|
||||||
|
GADFLY_SYSTEM_FILE=scripts/system-prompt.txt ./gadfly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release / deploy
|
||||||
|
|
||||||
|
- **Push to `main`** → CI builds and pushes `:latest` (+ `:sha-<short>`).
|
||||||
|
- **Tag `v*`** → publishes `:<tag>` (+ `:latest`). Pin consumers to `:vN` for stability.
|
||||||
|
- Required CI secrets: `REGISTRY_USER` / `REGISTRY_PASSWORD` (registry push + read access to the
|
||||||
|
private majordomo module). Optional `DISCORD_WEBHOOK_URL`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The full env reference lives in the **README** (`Specialists`, `Models & providers`,
|
||||||
|
`Configuration`). Provider selection: `GADFLY_PROVIDER` (default `ollama-cloud`),
|
||||||
|
`GADFLY_MODEL`/`GADFLY_MODELS`, `GADFLY_BASE_URL`, `GADFLY_API_KEY`. Named endpoint aliases via
|
||||||
|
`GADFLY_ENDPOINT_<NAME>` / `GADFLY_ALIAS_<NAME>` (http-capable) and majordomo `LLM_*` DSNs
|
||||||
|
(HTTPS-only).
|
||||||
|
|
||||||
|
**Specialists (the swarm):** the reviewer runs a suite of focused lenses, one consolidated
|
||||||
|
comment with a section each. Default suite = security/correctness/maintainability/performance/
|
||||||
|
error-handling; opt-in built-ins = tests/docs/conventions/improvements. Select via
|
||||||
|
`GADFLY_SPECIALISTS` (csv or `all`); define/override via `GADFLY_SPECIALIST_<NAME>` env or a repo
|
||||||
|
`.gadfly.yml` (`specialists:` + `define:`). See `cmd/gadfly/specialists.go`. Cost ≈
|
||||||
|
specialists × models × 2 passes — the **image/entrypoint** default stays minimal (one model) for
|
||||||
|
that reason; the **reusable workflow** (`review-reusable.yml`) deliberately ships a heavier
|
||||||
|
opinionated default swarm (3 cloud + Claude Code, 5 lenses) for steve's own fleet, which consumers
|
||||||
|
inherit or override per-input.
|
||||||
|
**Dynamic `auto`** (`GADFLY_SPECIALISTS=auto`): a selector (`GADFLY_SELECTOR_MODEL` or the review
|
||||||
|
model) picks lenses per-diff and may invent ad-hoc ones (`cmd/gadfly/auto.go`). **Worker-tier**
|
||||||
|
(`GADFLY_WORKER_MODEL`): a `delegate_investigation` tool offloads grep/read legwork to a cheap
|
||||||
|
sub-agent (`cmd/gadfly/delegate.go`).
|
||||||
|
|
||||||
|
**Tested vs untested:** only the Ollama paths (local + OpenAI-compatible pointed at Ollama)
|
||||||
|
are actually exercised. OpenAI/Anthropic/Google come from majordomo's abstraction and are
|
||||||
|
**untested** (no spend). Keep the README honest about this; update it if that changes.
|
||||||
|
|
||||||
|
## When making changes — maintenance rules
|
||||||
|
|
||||||
|
- **Keep the README and `examples/` current.** Any change to env vars, flags, defaults,
|
||||||
|
triggers, provider support, or the consumer stub MUST be reflected in `README.md` and the
|
||||||
|
relevant files under `examples/` in the *same* change. The README's `Configuration` table,
|
||||||
|
the `Models & providers` table, and the example workflows are the contract users rely on —
|
||||||
|
stale docs are a bug.
|
||||||
|
- **Preserve the advisory-only invariant** (goal #2). If you touch exit codes or the workflow,
|
||||||
|
re-confirm a review can never fail/block a consumer's CI.
|
||||||
|
- **Don't add mort-specific (or any single-consumer) assumptions** to the binary or system
|
||||||
|
prompt. The system prompt is intentionally generic; repo-specific conventions should be
|
||||||
|
discovered by the agent at runtime (it can read the repo's own CONTRIBUTING/CLAUDE.md), not
|
||||||
|
hardcoded here.
|
||||||
|
- **Keep secrets out of image layers.** Private-module creds flow via BuildKit `--mount=type=secret`
|
||||||
|
in the build stage only; never bake them into the final image or commit them.
|
||||||
|
- Add a test when you add logic (see the `*_test.go` patterns). Keep `gofmt` clean and `go vet` quiet.
|
||||||
|
|
||||||
|
## Lessons
|
||||||
|
|
||||||
|
- majordomo's `LLM_*` env DSNs are **HTTPS-only** (`DSN.BaseURL()` forces `https://`), so they
|
||||||
|
can't express a plaintext local Ollama. That's why Gadfly adds the http-capable
|
||||||
|
`GADFLY_ENDPOINT_<NAME>="provider|base-url[|key]"` mechanism (see `cmd/gadfly/model.go`).
|
||||||
|
- Gitea `vars`/`secrets` are **not** auto-exposed as env in a job — the consumer stub must map
|
||||||
|
each one explicitly in its `env:` block (dynamic alias names can't be auto-enumerated).
|
||||||
|
- **`uses: docker://…:latest` is CACHED by act_runner** — a freshly-pushed `:latest` is often
|
||||||
|
NOT re-pulled, so the job silently runs the previous image. For a run that must use a specific
|
||||||
|
build (e.g. validating a just-pushed fix), pin the consumer stub to the immutable
|
||||||
|
`:sha-<short>` tag the build publishes, not `:latest`.
|
||||||
|
- **Concurrency is per-provider** (`entrypoint.sh`): each provider is a lane, lanes run in
|
||||||
|
parallel, `cap` (from `GADFLY_PROVIDER_CONCURRENCY` else `GADFLY_CONCURRENCY`, default 1) bounds
|
||||||
|
models-at-once within a lane. The review timeout (`GADFLY_TIMEOUT_SECS`) is **per-lens**, not
|
||||||
|
shared across the suite — a slow model can't starve later lenses (the original timeout bug).
|
||||||
+20
-13
@@ -1,30 +1,37 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# Multi-stage so the private-module access token used to fetch the majordomo
|
# Multi-stage so the private-module credentials (used to fetch the majordomo
|
||||||
# dependency lives ONLY in the build stage and never lands in the final image.
|
# dependency) live ONLY in the build stage via BuildKit secrets and never land
|
||||||
|
# in the final image. Mirrors mort's Dockerfile secret idiom.
|
||||||
|
|
||||||
FROM golang:1.26 AS build
|
FROM golang:1.26 AS build
|
||||||
ARG GIT_HOST=gitea.stevedudenhoeffer.com
|
ARG GIT_HOST=gitea.stevedudenhoeffer.com
|
||||||
ARG GIT_USER=
|
|
||||||
ARG GIT_TOKEN=
|
|
||||||
ENV CGO_ENABLED=0 \
|
ENV CGO_ENABLED=0 \
|
||||||
GOFLAGS=-mod=mod \
|
GOFLAGS=-mod=mod \
|
||||||
GOSUMDB=off
|
GOSUMDB=off \
|
||||||
|
GOTOOLCHAIN=auto
|
||||||
ENV GOPRIVATE=${GIT_HOST}/* GONOSUMDB=${GIT_HOST}/*
|
ENV GOPRIVATE=${GIT_HOST}/* GONOSUMDB=${GIT_HOST}/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
# Private Go module access (majordomo). Token is confined to this stage.
|
|
||||||
RUN if [ -n "$GIT_TOKEN" ]; then \
|
|
||||||
git config --global url."https://${GIT_USER}:${GIT_TOKEN}@${GIT_HOST}/".insteadOf "https://${GIT_HOST}/"; \
|
|
||||||
fi
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=secret,id=REGISTRY_USER \
|
||||||
|
--mount=type=secret,id=REGISTRY_PASSWORD \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
|
git config --global url."https://$(cat /run/secrets/REGISTRY_USER):$(cat /run/secrets/REGISTRY_PASSWORD)@${GIT_HOST}/".insteadOf "https://${GIT_HOST}/" \
|
||||||
|
&& go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go build -trimpath -ldflags="-s -w" -o /out/gadfly ./cmd/gadfly
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o /out/gadfly ./cmd/gadfly
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache bash git curl jq ca-certificates
|
RUN apk add --no-cache bash git curl jq ca-certificates nodejs npm
|
||||||
|
# Bundle the Claude Code CLI so the `claude-code` review engine works out of the
|
||||||
|
# box (GADFLY_MODELS=claude-code or claude-code/<model>). This adds Node + the
|
||||||
|
# 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.
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code && npm cache clean --force
|
||||||
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
|
||||||
RUN chmod +x /entrypoint.sh /app/scripts/run.sh /usr/local/bin/gadfly
|
RUN chmod +x /entrypoint.sh /app/scripts/run.sh /app/scripts/status-board.sh /usr/local/bin/gadfly
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -36,11 +36,18 @@ or **Blocking issues found**.
|
|||||||
Gadfly ships as a container image, so consuming repos don't build anything — they just run
|
Gadfly ships as a container image, so consuming repos don't build anything — they just run
|
||||||
it. Drop one file in your repo and set a couple of secrets/vars:
|
it. Drop one file in your repo and set a couple of secrets/vars:
|
||||||
|
|
||||||
1. Copy [`examples/adversarial-review.yml`](examples/adversarial-review.yml) to
|
1. Copy a stub from [`examples/`](examples/) to `.gitea/workflows/adversarial-review.yml` in
|
||||||
`.gitea/workflows/adversarial-review.yml` in your repo.
|
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@…`,
|
||||||
|
forwarding only the secrets the reviewer needs), whose **default swarm is set centrally via owner
|
||||||
|
variables** (see [Central config via variables](#central-config-via-variables)) and inherited by omitting `with:` — 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:
|
2. Add repo config:
|
||||||
- **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty
|
- **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty
|
||||||
⇒ Gadfly posts a harmless "not configured" notice instead of reviewing).
|
⇒ Gadfly posts a harmless "not configured" notice instead of reviewing). *Not needed if
|
||||||
|
you point Gadfly at a different provider — see [Models & providers](#models--providers).*
|
||||||
- **var** `OLLAMA_REVIEW_MODELS` *(optional)* — comma-separated model ids
|
- **var** `OLLAMA_REVIEW_MODELS` *(optional)* — comma-separated model ids
|
||||||
(default `qwen3-coder:480b-cloud,gpt-oss:120b-cloud`). One comment per model.
|
(default `qwen3-coder:480b-cloud,gpt-oss:120b-cloud`). One comment per model.
|
||||||
- **var** `GADFLY_ALLOWED_USERS` *(optional)* — who may re-trigger via comment; empty ⇒
|
- **var** `GADFLY_ALLOWED_USERS` *(optional)* — who may re-trigger via comment; empty ⇒
|
||||||
@@ -49,6 +56,227 @@ it. Drop one file in your repo and set a couple of secrets/vars:
|
|||||||
`GITEA_TOKEN` is provided automatically by Actions; comments post as the `gitea-actions`
|
`GITEA_TOKEN` is provided automatically by Actions; comments post as the `gitea-actions`
|
||||||
user, scoped to that repo — no bot account needed.
|
user, scoped to that repo — no bot account needed.
|
||||||
|
|
||||||
|
## Models & providers
|
||||||
|
|
||||||
|
Gadfly is built on [majordomo](https://gitea.stevedudenhoeffer.com/steve/majordomo), so the
|
||||||
|
reviewer model is not hard-wired — it can target anything majordomo supports. Pick a provider
|
||||||
|
by setting `GADFLY_PROVIDER` (used to prefix bare model ids); point at a custom endpoint with
|
||||||
|
`GADFLY_BASE_URL`; supply a key with `GADFLY_API_KEY` or the provider's standard env var. A
|
||||||
|
`GADFLY_MODEL`/`GADFLY_MODELS` value that already contains a `provider/` prefix (or is a
|
||||||
|
majordomo failover chain / alias) is used verbatim.
|
||||||
|
|
||||||
|
| Provider | `GADFLY_PROVIDER` | Key env | Status |
|
||||||
|
|----------|-------------------|---------|--------|
|
||||||
|
| **Ollama Cloud** (default) | `ollama-cloud` | `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` | ✅ in active use |
|
||||||
|
| **Local Ollama** | `ollama` | none (`OLLAMA_HOST` or `GADFLY_BASE_URL` for a remote daemon) | ✅ tested |
|
||||||
|
| **[foreman](https://gitea.stevedudenhoeffer.com/steve/foreman)** (native-Ollama queue daemon) | `foreman` + `GADFLY_BASE_URL`, or a `GADFLY_ENDPOINT_*` / `LLM_*` `foreman://` entry | optional bearer (via the endpoint/DSN) | ✅ native-Ollama path |
|
||||||
|
| **[llama-swap](https://github.com/mostlygeek/llama-swap)** (model-swapping proxy) | `llama-swap`/`llama-swaps` (un-hyphenated `llamaswap`/`llamaswaps` also accepted) + `GADFLY_BASE_URL` or a `GADFLY_ENDPOINT_*` entry, or an `LLM_*` `llama-swap://` / `llama-swaps://` DSN | optional bearer | ⚠️ wired, **untested** |
|
||||||
|
| **OpenAI-compatible** (incl. local Ollama's `/v1`) | `openai` + `GADFLY_BASE_URL` | `OPENAI_API_KEY` (any non-empty for Ollama) | ✅ tested against Ollama |
|
||||||
|
| **OpenAI** | `openai` | `OPENAI_API_KEY` | ⚠️ wired, **untested** |
|
||||||
|
| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** |
|
||||||
|
| **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** |
|
||||||
|
|
||||||
|
> ### 🧪 Honest status
|
||||||
|
> Only the **Ollama** paths above are actually exercised. The OpenAI / Anthropic / Google
|
||||||
|
> providers come "for free" from majordomo's abstraction and *should* work, but I haven't
|
||||||
|
> spent money verifying them — treat them as untested. The OpenAI-**compatible** path **is**
|
||||||
|
> tested, because you can point it at a local Ollama (`GADFLY_BASE_URL=http://localhost:11434/v1`)
|
||||||
|
> and exercise the exact same code an OpenAI/OpenRouter endpoint would hit, for free. If you
|
||||||
|
> try a cloud provider and it works (or doesn't), please open an issue.
|
||||||
|
|
||||||
|
### Claude Code engine (`claude-code`)
|
||||||
|
|
||||||
|
Besides the majordomo model loop, Gadfly can review through the **[Claude Code](https://claude.com/claude-code)
|
||||||
|
CLI**: for each lens it shells out to `claude -p` *inside the checked-out repo*, so Claude Code
|
||||||
|
uses its **own** read tools (Read/Grep/Glob) to verify findings against real code, then Gadfly
|
||||||
|
parses the result and runs the same verdict-parse → recheck → consolidate → emit pipeline. The
|
||||||
|
CLI is bundled in the image (Node + `@anthropic-ai/claude-code`).
|
||||||
|
|
||||||
|
Select it as a model id — bare `claude-code` (CLI default model) or `claude-code/<model>` (the
|
||||||
|
suffix becomes `--model`, e.g. `claude-code/sonnet`, `claude-code/opus`). An optional
|
||||||
|
`:<thinking>` suffix forces an extended-thinking budget for that reviewer — `:max` (the high
|
||||||
|
"ultrathink" tier) or `:<n>` for a specific token budget — so you can run the same model at two
|
||||||
|
thinking depths as separate reviewers:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
GADFLY_MODELS: "claude-code/sonnet,claude-code/opus,claude-code/opus:max"
|
||||||
|
```
|
||||||
|
|
||||||
|
The thinking budget is applied via the `MAX_THINKING_TOKENS` env on the CLI subprocess; it's
|
||||||
|
best-effort (a no-op if the installed CLI build doesn't honor it).
|
||||||
|
|
||||||
|
Auth is read from the environment: the default is a **Pro/Max subscription** via
|
||||||
|
`CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`; no `--bare`), falling back to
|
||||||
|
`ANTHROPIC_API_KEY`. Don't set both. Tuning knobs (all optional):
|
||||||
|
|
||||||
|
| Env | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `GADFLY_CLAUDE_MODEL` | *(from the spec suffix)* | overrides the `--model` value |
|
||||||
|
| `GADFLY_CLAUDE_PERMISSION_MODE` | `plan` | `--permission-mode` (read-only `plan` keeps it from editing) |
|
||||||
|
| `GADFLY_CLAUDE_ALLOWED_TOOLS` | *(unset)* | `--allowedTools` value, passed verbatim (e.g. `Read,Grep,Glob`) |
|
||||||
|
| `GADFLY_CLAUDE_EXTRA_ARGS` | *(unset)* | extra CLI args, **whitespace-split** (no shell quoting) and appended after the defaults (e.g. `--max-turns 30`) |
|
||||||
|
| `GADFLY_CLAUDE_BIN` | `claude` | CLI binary path |
|
||||||
|
|
||||||
|
> These are **operator** knobs (workflow env), not PR-author input. Because
|
||||||
|
> `GADFLY_CLAUDE_EXTRA_ARGS` is appended *after* the defaults, it can override the
|
||||||
|
> read-only `--permission-mode plan` (e.g. passing `--permission-mode acceptEdits`),
|
||||||
|
> so keep it read-only unless you mean otherwise. It's whitespace-split, so values
|
||||||
|
> can't contain spaces — use `GADFLY_CLAUDE_ALLOWED_TOOLS` / `_PERMISSION_MODE` /
|
||||||
|
> `_MODEL` for those. The subprocess runs with a **minimal environment** (its auth
|
||||||
|
> token + `PATH`/`HOME`/locale/`GADFLY_CLAUDE_*`), not the runner's full env, so the
|
||||||
|
> Gitea token and provider keys aren't handed to the CLI.
|
||||||
|
|
||||||
|
**Alternate backends (example only, not validated here).** Because the subprocess env forwards
|
||||||
|
`ANTHROPIC_*` and `CLAUDE_*`, you can point the same engine at a non-Anthropic backend by setting
|
||||||
|
`ANTHROPIC_BASE_URL` (and `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`) to an **Anthropic-API-compatible
|
||||||
|
proxy** — e.g. [claude-code-router](https://github.com/musistudio/claude-code-router) or LiteLLM in
|
||||||
|
front of Ollama — to run *Ollama models through Claude Code's harness* and compare it against the
|
||||||
|
native majordomo loop. Whether tool-use survives a given proxy/backend varies, so this is documented
|
||||||
|
as an example, not wired or tested here.
|
||||||
|
|
||||||
|
> **The Pro/Max path is dogfooded but otherwise lightly tested.** `claude-code/sonnet` now runs on
|
||||||
|
> gadfly's own PRs (see `.gitea/workflows/adversarial-review.yml`), but treat the engine as new —
|
||||||
|
> and note that subscription auth in automated CI is a gray area in Anthropic's terms. `auto`
|
||||||
|
> specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped
|
||||||
|
> with this engine (Claude Code does its own legwork).
|
||||||
|
|
||||||
|
### Endpoint aliases via env vars
|
||||||
|
|
||||||
|
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
|
||||||
|
name with env vars and then reference `name/model` in `GADFLY_MODEL`/`GADFLY_MODELS`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# http-capable (Gadfly-native) — base URL used verbatim, so plaintext LAN works:
|
||||||
|
GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
|
||||||
|
GADFLY_ENDPOINT_GPU="openai|http://gpu.lan:8000/v1|sk-local"
|
||||||
|
GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok" # native-Ollama queue daemon
|
||||||
|
GADFLY_MODELS="bigbox/qwen2.5-coder:7b,gpu/llama3.1,m1/qwen3:14b"
|
||||||
|
|
||||||
|
# pure spec alias (a model, or a failover chain):
|
||||||
|
GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud"
|
||||||
|
GADFLY_MODEL="fast"
|
||||||
|
```
|
||||||
|
|
||||||
|
`<NAME>` is lowercased to form the registry name (`GADFLY_ENDPOINT_BIGBOX` → `bigbox`). This
|
||||||
|
is the same idea as majordomo's built-in **`LLM_*` env DSNs** (`LLM_BIGBOX=ollama://tok@host`,
|
||||||
|
`LLM_M1=foreman://tok@host`), which Gadfly also honors — but those are **HTTPS-only**, so for a
|
||||||
|
plaintext local Ollama or `http://` foreman use `GADFLY_ENDPOINT_*` instead.
|
||||||
|
|
||||||
|
> **Gitea Actions note:** repo `vars`/`secrets` aren't auto-exposed as env — add each alias to
|
||||||
|
> the stub workflow's `env:` block, e.g. `GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }}`.
|
||||||
|
|
||||||
|
## Specialists (the review swarm)
|
||||||
|
|
||||||
|
Instead of one generic reviewer, Gadfly runs a **suite of specialists** — each a focused lens
|
||||||
|
with its own review (+recheck) pass — and merges them into **one comment**, a collapsible
|
||||||
|
section per lens, led by an overall verdict (the worst across lenses; the optional
|
||||||
|
`improvements` lens never escalates it).
|
||||||
|
|
||||||
|
**Default suite** (when nothing is configured):
|
||||||
|
`security`, `correctness`, `maintainability` (code cleanliness), `performance`, `error-handling`.
|
||||||
|
|
||||||
|
**Also built in** (opt-in by name): `tests`, `docs`, `conventions`, and `improvements`
|
||||||
|
(strict & quiet — at most 1–2 high-value, non-blocking suggestions, silent otherwise).
|
||||||
|
|
||||||
|
Select which run with **`GADFLY_SPECIALISTS`** (comma-separated names, or `all`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
GADFLY_SPECIALISTS: "security,correctness,maintainability,tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Define your own** — two ways, which compose (env overrides file overrides built-ins):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 1. env: GADFLY_SPECIALIST_<NAME>="<focus>" (also overrides a built-in by reusing its name)
|
||||||
|
GADFLY_SPECIALIST_MIGRATIONS: "Review DB migrations for destructive or unindexed changes."
|
||||||
|
GADFLY_SPECIALISTS: "security,correctness,migrations"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 2. a repo .gadfly.yml at the repo root (version-controlled). See examples/.gadfly.yml:
|
||||||
|
specialists: [security, correctness, maintainability, migrations]
|
||||||
|
define:
|
||||||
|
- name: migrations
|
||||||
|
title: "🗃️ DB migrations"
|
||||||
|
focus: "Review schema migrations for destructive ops, missing indexes, table locks."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dynamic selection (`auto`):** set `GADFLY_SPECIALISTS: auto` and a selector model reads the
|
||||||
|
changed files + PR description and picks only the lenses that materially apply (and may invent
|
||||||
|
an ad-hoc one — e.g. a "migrations" lens for a schema change). The selector is
|
||||||
|
`GADFLY_SELECTOR_MODEL` if set (a cheap tier is ideal), else the review model. Capped and
|
||||||
|
de-duplicated; falls back to the default suite if selection fails.
|
||||||
|
|
||||||
|
**Worker-tier delegation:** set `GADFLY_WORKER_MODEL` (a cheap/fast model) to give every
|
||||||
|
reviewer a `delegate_investigation` tool — it offloads mechanical legwork (trace all callers,
|
||||||
|
gather every usage, check a pattern across files) to a worker sub-agent that returns a concise,
|
||||||
|
evidence-cited digest, so the expensive model reasons over summaries instead of raw file dumps.
|
||||||
|
Unset = no delegation (current behavior).
|
||||||
|
|
||||||
|
> **Cost:** each specialist is its own review+recheck, so cost ≈ *specialists × models × 2*.
|
||||||
|
> The default suite runs on a **single** model. Trim with `GADFLY_SPECIALISTS`, let `auto` pick
|
||||||
|
> only what a diff needs, and point heavy legwork at a cheap `GADFLY_WORKER_MODEL`.
|
||||||
|
|
||||||
|
### Concurrency (per-provider lanes)
|
||||||
|
|
||||||
|
With multiple models, each **provider** is its own lane and lanes run in **parallel**, so a fast
|
||||||
|
cloud provider isn't stuck behind a slow local box. Within a lane, at most `cap` models run at
|
||||||
|
once — `cap` comes from `GADFLY_PROVIDER_CONCURRENCY` (a `provider=N` map) else `GADFLY_CONCURRENCY`
|
||||||
|
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow model on one lens
|
||||||
|
can't starve the others.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# One local box (serial — it serves one model at a time) + 3 cloud reviews at once,
|
||||||
|
# both lanes running concurrently:
|
||||||
|
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=3,m1pro=1"
|
||||||
|
GADFLY_MODELS: "m1pro/qwen3:14b,qwen3-coder:480b-cloud,gpt-oss:120b-cloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
A model's provider is the spec's first segment (`m1pro/…` → `m1pro`), or `GADFLY_PROVIDER`/
|
||||||
|
`ollama-cloud` for a bare id. Default (`cap 1`) keeps a single-provider pool fully sequential.
|
||||||
|
|
||||||
|
**Lens fan-out (within a model).** By default the specialist lenses run **sequentially** inside
|
||||||
|
each model (`GADFLY_LENS_CONCURRENCY=1`). Raise it to overlap the independent per-lens
|
||||||
|
review+recheck passes — the model then posts its consolidated comment as soon as its lenses
|
||||||
|
finish (so with sequential models, results stream in per model and per-model timings stay
|
||||||
|
clean). Like the model cap, it's **per-provider configurable**: `GADFLY_PROVIDER_LENS_CONCURRENCY`
|
||||||
|
takes a `provider=N` map keyed by the **same provider lanes** as `GADFLY_PROVIDER_CONCURRENCY`,
|
||||||
|
falling back to the `GADFLY_LENS_CONCURRENCY` scalar (default `1`). **It multiplies with the
|
||||||
|
model cap:** total in-flight requests ≈ *models-at-once × lenses-at-once*, so to fan lenses out
|
||||||
|
without oversubscribing a backend, keep its model cap low and raise its lens cap:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Per provider: cloud runs one model at a time but fans its 3 lenses out (3 concurrent requests);
|
||||||
|
# the slow local box stays fully serial. Both provider lanes still run in parallel.
|
||||||
|
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
|
||||||
|
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
|
||||||
|
GADFLY_SPECIALISTS: "security,correctness,error-handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Live status board
|
||||||
|
|
||||||
|
When several models (each with several lenses) review a PR, the individual findings land in
|
||||||
|
**one comment per model** — but while that's in flight all you'd see is a row of
|
||||||
|
`⏳ Reviewing…` placeholders. So Gadfly also upserts **one consolidated status-board comment**
|
||||||
|
that aggregates every model's per-lens progress as it happens:
|
||||||
|
|
||||||
|
```
|
||||||
|
## 🪰 Gadfly — live review status
|
||||||
|
1/3 reviewers finished · updated 2026-06-27 18:14:56Z
|
||||||
|
|
||||||
|
#### `glm-5.2:cloud` · ollama-cloud — ⏳ 2/4 lenses
|
||||||
|
- ✅ security — No material issues found
|
||||||
|
- 🔄 correctness — running
|
||||||
|
- ⏸️ performance — queued
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
Each model process publishes its lenses (queued → running → finished + verdict) to a small
|
||||||
|
JSON file, and a background renderer in `entrypoint.sh` re-renders + upserts the single comment
|
||||||
|
every `GADFLY_STATUS_POLL_SECS` (default 12s) until the swarm finishes. It's advisory and
|
||||||
|
best-effort — the per-model findings comments are unaffected — and entirely separate from those.
|
||||||
|
Turn it off with `GADFLY_STATUS_BOARD=0`.
|
||||||
|
|
||||||
### Triggers
|
### Triggers
|
||||||
|
|
||||||
1. A **new/reopened/ready** non-draft PR — automatic.
|
1. A **new/reopened/ready** non-draft PR — automatic.
|
||||||
@@ -58,20 +286,55 @@ user, scoped to that repo — no bot account needed.
|
|||||||
(Pushing new commits does *not* auto-re-review — comment `@gadfly review` after pushing
|
(Pushing new commits does *not* auto-re-review — comment `@gadfly review` after pushing
|
||||||
fixes. This keeps usage down.)
|
fixes. This keeps usage down.)
|
||||||
|
|
||||||
|
> **Comment trigger needs the workflow on your default branch.** Gitea runs `issue_comment`
|
||||||
|
> workflows from the **default branch**, so `@gadfly review` only works once this stub is
|
||||||
|
> merged to `main` (the `pull_request` auto-trigger works from the PR branch immediately).
|
||||||
|
>
|
||||||
|
> **Security:** the example stubs gate the comment trigger with a job-level
|
||||||
|
> `if: github.event_name != 'issue_comment' || github.actor == '<you>'` so an untrusted
|
||||||
|
> commenter can't start a secret-bearing run — edit it to your maintainers and keep it in
|
||||||
|
> sync with `GADFLY_ALLOWED_USERS` (the in-container check). `@gadfly review` is plain-text
|
||||||
|
> matched (configurable via `GADFLY_TRIGGER_PHRASE`), so no bot account is required; comments
|
||||||
|
> post as `gitea-actions`.
|
||||||
|
|
||||||
## How it's packaged
|
## How it's packaged
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/gadfly/ the agentic reviewer binary (majordomo + Ollama Cloud); zero deps beyond stdlib + majordomo
|
cmd/gadfly/ the agentic reviewer binary (majordomo + Ollama Cloud); zero deps beyond stdlib + majordomo
|
||||||
scripts/run.sh fetches the PR diff, runs the reviewer, upserts one labeled comment
|
scripts/run.sh fetches the PR diff, runs the reviewer, upserts one labeled comment
|
||||||
|
scripts/status-board.sh renders + upserts the single live status-board comment (per-lens progress)
|
||||||
scripts/system-prompt.txt the reviewer persona + verification discipline
|
scripts/system-prompt.txt the reviewer persona + verification discipline
|
||||||
entrypoint.sh the container brains: trigger gating, clone, model loop (logic lives here, not in YAML)
|
entrypoint.sh the container brains: trigger gating, clone, model loop (logic lives here, not in YAML)
|
||||||
Dockerfile multi-stage; the build-time module token never reaches the final image
|
Dockerfile multi-stage; build-time module creds (BuildKit secrets) never reach the final image
|
||||||
.gitea/workflows/build-image.yml tags v* → build & push the image
|
.gitea/workflows/build-image.yml push to main → :latest; tag v* → :<tag> + :latest
|
||||||
examples/ the ~15-line stub a consuming repo drops in
|
examples/ the ~15-line stub a consuming repo drops in
|
||||||
```
|
```
|
||||||
|
|
||||||
The image is published to `gitea.stevedudenhoeffer.com/steve/gadfly`. Push a `v*` tag to
|
The image is published to `gitea.stevedudenhoeffer.com/steve/gadfly`. Every push to `main`
|
||||||
build and publish a new version (and `:latest`).
|
rebuilds and republishes `:latest` (plus `:sha-<short>`); pushing a `v*` tag publishes that
|
||||||
|
pinned version (plus `:latest`). Pin full-stub consumers to a `:vN` image tag for stability, or track
|
||||||
|
`:latest` to ride main. **Reusable-workflow consumers should pin the workflow ref to an immutable
|
||||||
|
`review-reusable.yml@<sha>`** — long-lived act_runners *cache the workflow file by ref*, so a moved tag
|
||||||
|
(`@v1`) or `@main` is often **not** re-fetched and silently runs a stale copy. A fresh `@<sha>` is the
|
||||||
|
only reliable way to roll out a *structural* change to the reusable.
|
||||||
|
|
||||||
|
### Central config via variables
|
||||||
|
|
||||||
|
So you don't have to re-pin every consumer just to retune the swarm, the reusable resolves its config
|
||||||
|
at **runtime** — `with:` input → owner **user/org-level variable** → image default — and variables are
|
||||||
|
injected per-run (not part of the cached file), so changing one variable propagates to every consumer
|
||||||
|
on its next review **without** a re-pin or a tag move:
|
||||||
|
|
||||||
|
| Variable (user/org scope) | Sets |
|
||||||
|
|---|---|
|
||||||
|
| `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) |
|
||||||
|
| `GADFLY_DEFAULT_SPECIALISTS` | the lens suite |
|
||||||
|
| `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` | models-at-once per provider |
|
||||||
|
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | lenses-at-once per provider |
|
||||||
|
| `GADFLY_ENDPOINT_RAGNAROS` | a named endpoint, e.g. `llamaswap\|https://host` |
|
||||||
|
|
||||||
|
Adding a *new* named endpoint still needs a one-line reusable edit (Gitea can't auto-expose arbitrary
|
||||||
|
`vars.GADFLY_ENDPOINT_*`); the values of already-wired ones are pure variables.
|
||||||
|
|
||||||
## Configuration (advanced)
|
## Configuration (advanced)
|
||||||
|
|
||||||
@@ -79,15 +342,52 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults):
|
|||||||
|
|
||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `OLLAMA_API_KEY` | — | Ollama Cloud bearer key (required for real reviews) |
|
| `GADFLY_MODEL` | — | model id, or `provider/model` spec, or majordomo alias/chain |
|
||||||
| `GADFLY_MODEL` | — | model id |
|
| `GADFLY_PROVIDER` | `ollama-cloud` | provider prefix for a bare model id |
|
||||||
|
| `GADFLY_BASE_URL` | — | override endpoint (OpenAI/Ollama-compatible servers) |
|
||||||
|
| `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 |
|
||||||
|
| `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_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
|
||||||
|
| `GADFLY_WORKER_MAX_STEPS` | 8 | tool-step cap for a delegated worker run |
|
||||||
|
| `GADFLY_CONCURRENCY` | 1 | default max models run at once **per provider** |
|
||||||
|
| `GADFLY_PROVIDER_CONCURRENCY` | — | per-provider overrides, e.g. `ollama-cloud=3,m1pro=1` |
|
||||||
|
| `GADFLY_LENS_CONCURRENCY` | 1 | specialist lenses run at once **within a model** (× model cap = total in-flight) |
|
||||||
|
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens overrides, same lanes as `GADFLY_PROVIDER_CONCURRENCY`, e.g. `ollama-cloud=3,m1=1` |
|
||||||
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap |
|
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap |
|
||||||
|
| `GADFLY_TIMEOUT_SECS` | 300 | deadline **per specialist lens** (review+recheck) |
|
||||||
| `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass |
|
| `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass |
|
||||||
| `GADFLY_RECHECK_MAX_STEPS` | 16 | recheck-pass step cap |
|
| `GADFLY_RECHECK_MAX_STEPS` | 16 | recheck-pass step cap |
|
||||||
| `GADFLY_TIMEOUT_SECS` | 300 | overall deadline (both passes) |
|
|
||||||
| `GADFLY_MAX_DIFF_CHARS` | 60000 | diff chars embedded in the prompt (full diff via `get_diff`) |
|
| `GADFLY_MAX_DIFF_CHARS` | 60000 | diff chars embedded in the prompt (full diff via `get_diff`) |
|
||||||
|
| `GADFLY_STATUS_BOARD` | on | set `0` to disable the live status-board comment |
|
||||||
|
| `GADFLY_STATUS_POLL_SECS` | 12 | how often the status board re-renders/upserts |
|
||||||
| `GADFLY_TRIGGER_PHRASE` | `@gadfly review` | comment phrase that re-triggers |
|
| `GADFLY_TRIGGER_PHRASE` | `@gadfly review` | comment phrase that re-triggers |
|
||||||
| `GADFLY_ALLOWED_USERS` | *(collaborators)* | comma-separated allow-list for comment triggers |
|
| `GADFLY_ALLOWED_USERS` | *(collaborators)* | comma-separated allow-list for comment triggers |
|
||||||
|
| `GADFLY_FINDINGS_URL` | — | gadfly-reports store base URL; set to enable findings telemetry (off when empty) |
|
||||||
|
| `GADFLY_FINDINGS_TOKEN` | — | bearer token for the gadfly-reports store (sent as `Authorization: Bearer …`) |
|
||||||
|
| `GADFLY_REPO` | *(from `GITEA_API`)* | `owner/repo` slug stamped on emitted runs/findings (set by `entrypoint.sh`) |
|
||||||
|
| `GADFLY_PR` | *(from event)* | PR number stamped on emitted runs/findings (set by `entrypoint.sh`) |
|
||||||
|
|
||||||
|
## Findings telemetry (optional)
|
||||||
|
|
||||||
|
Gadfly can record what it found so model quality can be tracked over time. It is
|
||||||
|
**off by default** and purely advisory: set **`GADFLY_FINDINGS_URL`** to a
|
||||||
|
[gadfly-reports](https://gitea.stevedudenhoeffer.com/steve/gadfly-reports) store base URL and,
|
||||||
|
after each review, the binary best-effort `POST`s the run (`/runs`) and the
|
||||||
|
findings it surfaced (`/reports`) to that store. Add **`GADFLY_FINDINGS_TOKEN`**
|
||||||
|
to send an `Authorization: Bearer …` header. `entrypoint.sh` supplies the run
|
||||||
|
context (`GADFLY_REPO`, `GADFLY_PR`) automatically.
|
||||||
|
|
||||||
|
Findings are extracted heuristically from each lens's markdown — a `path:line`
|
||||||
|
reference anchors a finding, titled by the nearest preceding heading / numbered
|
||||||
|
item / bold lead-in. A lens whose verdict is **"No material issues found"**
|
||||||
|
emits **no** findings: its `path:line` references are verification notes
|
||||||
|
("verified X is safe"), not problems, so extracting them would record false
|
||||||
|
positives and unfairly penalize thorough clean-pass reviewers. The emit is
|
||||||
|
strictly best-effort: a short (~10s) timeout, any error (or a non-2xx response)
|
||||||
|
is logged to stderr only, and it **never** changes the review output or the exit
|
||||||
|
code.
|
||||||
|
|
||||||
## Building locally
|
## Building locally
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAutoSpecialists bounds how many lenses dynamic selection may run, so a
|
||||||
|
// runaway selector can't blow up cost.
|
||||||
|
const maxAutoSpecialists = 6
|
||||||
|
|
||||||
|
// autoCustom is an ad-hoc specialist the selector may propose when the change
|
||||||
|
// needs a lens the catalog lacks (e.g. DB migrations, i18n).
|
||||||
|
type autoCustom struct {
|
||||||
|
Name string `json:"name" description:"short lowercase id, e.g. migrations"`
|
||||||
|
Title string `json:"title" description:"human label for the section, e.g. 🗃️ DB migrations"`
|
||||||
|
Focus string `json:"focus" description:"one or two sentences telling the reviewer what to look for"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSelection is the structured result the selector returns.
|
||||||
|
type autoSelection struct {
|
||||||
|
Specialists []string `json:"specialists" description:"names of EXISTING catalog specialists that materially apply to this change"`
|
||||||
|
Custom []autoCustom `json:"custom" description:"ad-hoc specialists to add ONLY when the catalog lacks a clearly-needed lens; usually empty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSelectorModel returns the model that picks specialists in auto mode:
|
||||||
|
// GADFLY_SELECTOR_MODEL (a cheap tier is ideal) if set, else the review model.
|
||||||
|
func resolveSelectorModel(fallback llm.Model) (llm.Model, error) {
|
||||||
|
spec := strings.TrimSpace(os.Getenv("GADFLY_SELECTOR_MODEL"))
|
||||||
|
if spec == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
|
||||||
|
if provider == "" {
|
||||||
|
provider = defaultProvider
|
||||||
|
}
|
||||||
|
return majordomo.Parse(buildSpec(provider, spec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSelectSpecialists asks the selector model which lenses a given diff needs.
|
||||||
|
// It picks from the registry and may invent a few ad-hoc lenses, capped and
|
||||||
|
// de-duplicated. On any failure it returns an error and the caller falls back
|
||||||
|
// to the default suite.
|
||||||
|
func autoSelectSpecialists(ctx context.Context, selector llm.Model, title, body, diff string, registry map[string]Specialist) ([]Specialist, error) {
|
||||||
|
system := "You are the router for an adversarial code review. Given a pull request's " +
|
||||||
|
"changed files and description plus a catalog of available review specialists (lenses), " +
|
||||||
|
"choose the SMALLEST set that materially applies to THIS change — usually 2 to 4. Skip " +
|
||||||
|
"lenses the diff doesn't touch (e.g. no 'performance' for a docs-only change). Strongly " +
|
||||||
|
"prefer existing catalog lenses; only propose a custom one when the change clearly needs a " +
|
||||||
|
"lens the catalog lacks (e.g. database migrations, i18n, build/CI). Be conservative."
|
||||||
|
|
||||||
|
user := fmt.Sprintf("PR title: %s\n\nPR description:\n%s\n\nChanged files:\n%s\n\nAvailable specialists (name — focus):\n%s\n\nSelect the lenses to run.",
|
||||||
|
strings.TrimSpace(title), strings.TrimSpace(body), changedFilesList(diff), catalog(registry))
|
||||||
|
|
||||||
|
sel, err := majordomo.Generate[autoSelection](ctx, selector, majordomo.Request{
|
||||||
|
System: system,
|
||||||
|
Messages: []llm.Message{llm.UserText(user)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auto-select: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Specialist
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(sp Specialist) {
|
||||||
|
if sp.Name == "" || seen[sp.Name] || len(out) >= maxAutoSpecialists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[sp.Name] = true
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
for _, name := range sel.Specialists {
|
||||||
|
if sp, ok := registry[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||||
|
add(sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range sel.Custom {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||||
|
if name == "" || strings.TrimSpace(c.Focus) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sp, ok := registry[name]; ok { // prefer the catalog definition if it exists
|
||||||
|
add(sp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(Specialist{Name: name, Title: titleOr(c.Title, name), Focus: c.Focus})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("auto-select returned no usable specialists")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalog renders the registry as a compact name — focus list for the selector.
|
||||||
|
func catalog(registry map[string]Specialist) string {
|
||||||
|
names := make([]string, 0, len(registry))
|
||||||
|
for k := range registry {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Fprintf(&b, "- %s — %s\n", n, firstSentence(registry[n].Focus))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// changedFilesList extracts the changed file paths from a unified diff.
|
||||||
|
func changedFilesList(diff string) string {
|
||||||
|
var files []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(diff, "\n") {
|
||||||
|
if !strings.HasPrefix(line, "+++ ") && !strings.HasPrefix(line, "--- ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := strings.TrimSpace(line[4:])
|
||||||
|
if p == "/dev/null" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p = strings.TrimPrefix(strings.TrimPrefix(p, "a/"), "b/")
|
||||||
|
if p != "" && !seen[p] {
|
||||||
|
seen[p] = true
|
||||||
|
files = append(files, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "(could not parse file list; review the whole diff)"
|
||||||
|
}
|
||||||
|
return "- " + strings.Join(files, "\n- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstSentence(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '.'); i > 0 && i < 140 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
if len(s) > 140 {
|
||||||
|
return s[:140] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verdict is the severity a specialist (or the whole review) lands on.
|
||||||
|
type verdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
verdictUnknown verdict = iota // couldn't parse; treated below Minor for display, not blocking
|
||||||
|
verdictClean // "No material issues found"
|
||||||
|
verdictMinor // "Minor issues"
|
||||||
|
verdictBlocking // "Blocking issues found"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v verdict) label() string {
|
||||||
|
switch v {
|
||||||
|
case verdictClean:
|
||||||
|
return "No material issues found"
|
||||||
|
case verdictMinor:
|
||||||
|
return "Minor issues"
|
||||||
|
case verdictBlocking:
|
||||||
|
return "Blocking issues found"
|
||||||
|
default:
|
||||||
|
return "Reviewed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVerdict extracts a specialist's self-reported verdict from its output.
|
||||||
|
// The base prompt tells each lens to lead with one of the three phrases.
|
||||||
|
func parseVerdict(out string) verdict {
|
||||||
|
l := strings.ToLower(out)
|
||||||
|
// Match the EARLIEST-appearing verdict phrase (the lead verdict), and match
|
||||||
|
// leniently — models write "Blocking issues found", "**Blocking issues**",
|
||||||
|
// "blocking issue", etc. (A strict "blocking issues found" check let a
|
||||||
|
// gpt-oss section that said "**Blocking issues**" fall through to unknown,
|
||||||
|
// so the overall header wrongly read "No material issues found".)
|
||||||
|
best, bestIdx := verdictUnknown, -1
|
||||||
|
for _, c := range []struct {
|
||||||
|
phrase string
|
||||||
|
v verdict
|
||||||
|
}{
|
||||||
|
{"blocking issue", verdictBlocking},
|
||||||
|
{"minor issue", verdictMinor},
|
||||||
|
{"no material issue", verdictClean},
|
||||||
|
} {
|
||||||
|
if i := strings.Index(l, c.phrase); i >= 0 && (bestIdx == -1 || i < bestIdx) {
|
||||||
|
best, bestIdx = c.v, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// specialistResult pairs a specialist with its rendered review and verdict.
|
||||||
|
type specialistResult struct {
|
||||||
|
spec Specialist
|
||||||
|
out string
|
||||||
|
verdict verdict
|
||||||
|
errored bool // the review pass failed (timeout/model error) — not a clean result
|
||||||
|
}
|
||||||
|
|
||||||
|
// worstVerdict returns the most severe verdict across results. The optional
|
||||||
|
// "improvements" lens never escalates the overall verdict (it's advisory).
|
||||||
|
func worstVerdict(results []specialistResult) verdict {
|
||||||
|
worst := verdictClean
|
||||||
|
for _, r := range results {
|
||||||
|
if r.spec.Name == "improvements" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.verdict > worst {
|
||||||
|
worst = r.verdict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderConsolidated assembles the single comment body: an overall verdict line
|
||||||
|
// followed by one verbatim section per specialist. run.sh wraps this with the
|
||||||
|
// "🪰 Gadfly review — <model>" header and the advisory footer.
|
||||||
|
func renderConsolidated(results []specialistResult) string {
|
||||||
|
errored := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.errored {
|
||||||
|
errored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headline := "Verdict: " + worstVerdict(results).label()
|
||||||
|
if len(results) > 0 && errored == len(results) {
|
||||||
|
// Every lens errored — do NOT report this as "clean".
|
||||||
|
headline = "Review incomplete — all lenses errored"
|
||||||
|
} else if errored > 0 {
|
||||||
|
headline += fmt.Sprintf(" · ⚠️ %d/%d lens(es) errored", errored, len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "**%s** — %d reviewers: %s\n",
|
||||||
|
headline, len(results), strings.Join(specialistNames(results), ", "))
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
body := strings.TrimSpace(r.out)
|
||||||
|
if body == "" {
|
||||||
|
body = "_(no output)_"
|
||||||
|
}
|
||||||
|
summary := r.verdict.label()
|
||||||
|
if r.errored {
|
||||||
|
summary = "⚠️ could not complete"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n<details><summary><b>%s</b> — %s</summary>\n\n%s\n\n</details>\n",
|
||||||
|
r.spec.Title, summary, body)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func specialistNames(results []specialistResult) []string {
|
||||||
|
names := make([]string, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
names = append(names, r.spec.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultWorkerMaxSteps bounds a delegated worker run.
|
||||||
|
const defaultWorkerMaxSteps = 8
|
||||||
|
|
||||||
|
// workerSystemPrompt drives a delegated investigation sub-agent. It does the
|
||||||
|
// grunt work (grep/read) and returns a tight, evidence-cited digest so the
|
||||||
|
// top reviewer reasons over a summary instead of raw file dumps.
|
||||||
|
const workerSystemPrompt = "You are a fast code investigator. Use the read-only tools " +
|
||||||
|
"(read_file, grep, find_files, list_dir, get_diff) to answer the SPECIFIC question you are " +
|
||||||
|
"given about this repository — nothing more. Be precise and concise: report concrete findings " +
|
||||||
|
"with `path:line` evidence, quote only the few lines that matter, and state plainly when " +
|
||||||
|
"something is absent. Do NOT speculate or pad. When you have the answer, stop and report it."
|
||||||
|
|
||||||
|
type delegateArgs struct {
|
||||||
|
Task string `json:"task" description:"A specific investigation to carry out by reading/grepping the repo, e.g. 'find every caller of parseAmount and list which ones don't check the error'. Ask for a concrete answer, not an opinion."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// delegateTool lets the reviewer offload mechanical investigation to a (cheaper)
|
||||||
|
// worker model. Present only when GADFLY_WORKER_MODEL is configured.
|
||||||
|
func (r *repoFS) delegateTool() llm.Tool {
|
||||||
|
return llm.DefineTool[delegateArgs](
|
||||||
|
"delegate_investigation",
|
||||||
|
"Delegate focused legwork to a fast worker agent that reads/greps this repo and reports back "+
|
||||||
|
"a concise, evidence-cited digest. Use it to offload mechanical investigation (tracing all "+
|
||||||
|
"callers, gathering every usage of a symbol, checking a pattern across many files) so you can "+
|
||||||
|
"reason over the summary instead of pulling raw files into your own context.",
|
||||||
|
func(ctx context.Context, args delegateArgs) (any, error) {
|
||||||
|
task := strings.TrimSpace(args.Task)
|
||||||
|
if task == "" {
|
||||||
|
return nil, fmt.Errorf("task is required")
|
||||||
|
}
|
||||||
|
box, err := r.workerToolbox()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := agent.New(r.worker, workerSystemPrompt,
|
||||||
|
agent.WithToolbox(box),
|
||||||
|
agent.WithMaxSteps(envInt("GADFLY_WORKER_MAX_STEPS", defaultWorkerMaxSteps)),
|
||||||
|
agent.WithToolErrorLimits(3, 3),
|
||||||
|
)
|
||||||
|
res, runErr := w.Run(ctx, task)
|
||||||
|
out := ""
|
||||||
|
if res != nil {
|
||||||
|
out = strings.TrimSpace(res.Output)
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
if runErr != nil {
|
||||||
|
return nil, fmt.Errorf("worker investigation failed: %w", runErr)
|
||||||
|
}
|
||||||
|
return "worker returned no findings", nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Optional findings telemetry. After a review, Gadfly can POST the run and the
|
||||||
|
// findings it surfaced to a "gadfly-reports" store (gitea.stevedudenhoeffer.com-style
|
||||||
|
// HTTP API) so model quality can be tracked over time and graded later.
|
||||||
|
//
|
||||||
|
// This is strictly ADVISORY plumbing and must never affect the review itself:
|
||||||
|
// - It is OFF unless GADFLY_FINDINGS_URL is set.
|
||||||
|
// - It writes ONLY to stderr on any error; it never touches stdout (stdout is
|
||||||
|
// the review markdown consumed by run.sh) and never changes the exit code.
|
||||||
|
// - It depends only on the Go stdlib (net/http).
|
||||||
|
//
|
||||||
|
// Findings are extracted heuristically from each lens's markdown: a "path:line"
|
||||||
|
// reference (e.g. run/executor.go:166) anchors a finding, whose title is the
|
||||||
|
// nearest preceding markdown heading / numbered item / bold lead-in (else the
|
||||||
|
// first sentence of the finding's paragraph). This is best-effort signal for the
|
||||||
|
// store to aggregate — not a structured contract the reviewer guarantees.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// findingsHTTPTimeout bounds each telemetry POST. Short on purpose — a slow
|
||||||
|
// or dead store must never delay the review's stdout (already printed) or the
|
||||||
|
// process exit.
|
||||||
|
findingsHTTPTimeout = 10 * time.Second
|
||||||
|
// maxFindingsPerLens caps how many findings a single lens contributes, so a
|
||||||
|
// pathological lens output can't blow up the /reports payload.
|
||||||
|
maxFindingsPerLens = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// pathLineRe matches a source-path-with-extension followed by ":<line>", e.g.
|
||||||
|
// run/executor.go:166 or compact/compactor.go:225. The path may contain
|
||||||
|
// directories, dots, dashes and underscores; backtracking lets the final ".ext"
|
||||||
|
// be split off the greedy path run. Surrounding backticks/parens are ignored
|
||||||
|
// because they are not in the character class.
|
||||||
|
var pathLineRe = regexp.MustCompile(`([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):(\d+)`)
|
||||||
|
|
||||||
|
// numberedRe matches a numbered list/heading lead-in: "1. Title" or "1) Title".
|
||||||
|
var numberedRe = regexp.MustCompile(`^\d+[.)]\s+(.+)$`)
|
||||||
|
|
||||||
|
// finding is one extracted issue: where it points and a derived human title.
|
||||||
|
type finding struct {
|
||||||
|
file string
|
||||||
|
line int
|
||||||
|
title string
|
||||||
|
detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPayload is the POST /runs body. Field names match the gadfly-reports API EXACTLY.
|
||||||
|
// Token/cost fields are pointers so they marshal to JSON null when unknown
|
||||||
|
// (Gadfly does not currently meter tokens or cost).
|
||||||
|
type runPayload struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
PR int `json:"pr"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Lenses int `json:"lenses"`
|
||||||
|
DurationSecs float64 `json:"duration_secs"`
|
||||||
|
InputTokens *int `json:"input_tokens"`
|
||||||
|
OutputTokens *int `json:"output_tokens"`
|
||||||
|
CostUSD *float64 `json:"cost_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportPayload is one element of the POST /reports JSON array. Field names match
|
||||||
|
// the gadfly-reports API EXACTLY.
|
||||||
|
type reportPayload struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
PR int `json:"pr"`
|
||||||
|
Lens string `json:"lens"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
RawSeverity string `json:"raw_severity"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit posts the run and its findings to the gadfly-reports store. It is a no-op unless
|
||||||
|
// GADFLY_FINDINGS_URL is set. Best-effort: any error is logged to stderr and
|
||||||
|
// otherwise ignored. It NEVER returns an error, never calls os.Exit, and never
|
||||||
|
// writes to stdout.
|
||||||
|
func emit(results []specialistResult, elapsed time.Duration) {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(os.Getenv("GADFLY_FINDINGS_URL")), "/")
|
||||||
|
if base == "" {
|
||||||
|
return // telemetry disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := strings.TrimSpace(os.Getenv("GADFLY_REPO"))
|
||||||
|
pr := envAtoi("GADFLY_PR")
|
||||||
|
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
|
||||||
|
provider := modelProvider()
|
||||||
|
runID := fmt.Sprintf("%s#%d:%s", repo, pr, model)
|
||||||
|
|
||||||
|
run := runPayload{
|
||||||
|
RunID: runID,
|
||||||
|
Repo: repo,
|
||||||
|
PR: pr,
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
Lenses: len(results),
|
||||||
|
DurationSecs: elapsed.Seconds(),
|
||||||
|
// InputTokens/OutputTokens/CostUSD stay nil -> JSON null (not metered).
|
||||||
|
}
|
||||||
|
|
||||||
|
var reports []reportPayload
|
||||||
|
for _, r := range results {
|
||||||
|
if r.errored {
|
||||||
|
continue // a failed lens contributes no findings
|
||||||
|
}
|
||||||
|
// A lens that reports "No material issues found" has nothing to flag —
|
||||||
|
// its path:line references are verification notes ("verified X at
|
||||||
|
// file:line is safe"), not problems. Extracting them pollutes the
|
||||||
|
// findings store with false positives and unfairly penalizes thorough
|
||||||
|
// reviewers that do clean passes, so a clean lens emits no findings.
|
||||||
|
if r.verdict == verdictClean {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sev := r.verdict.label()
|
||||||
|
for _, f := range parseFindings(r.spec, r.out) {
|
||||||
|
reports = append(reports, reportPayload{
|
||||||
|
Repo: repo,
|
||||||
|
PR: pr,
|
||||||
|
Lens: r.spec.Name,
|
||||||
|
File: f.file,
|
||||||
|
Line: f.line,
|
||||||
|
Title: f.title,
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
RunID: runID,
|
||||||
|
RawSeverity: sev,
|
||||||
|
Detail: f.detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: findingsHTTPTimeout}
|
||||||
|
token := strings.TrimSpace(os.Getenv("GADFLY_FINDINGS_TOKEN"))
|
||||||
|
|
||||||
|
if err := postJSON(client, base+"/runs", token, run); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: findings emit (/runs) failed:", err)
|
||||||
|
}
|
||||||
|
if len(reports) > 0 {
|
||||||
|
if err := postJSON(client, base+"/reports", token, reports); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: findings emit (/reports) failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFindings extracts findings from one lens's markdown output. A lens whose
|
||||||
|
// result errored (its output is the inline failure notice) yields nothing.
|
||||||
|
// Findings are anchored on "path:line" references, deduped by (file,line) keeping
|
||||||
|
// the first occurrence, and capped at maxFindingsPerLens.
|
||||||
|
func parseFindings(sp Specialist, out string) []finding {
|
||||||
|
_ = sp // signature kept symmetric with the caller; lens identity isn't needed here
|
||||||
|
trimmed := strings.TrimSpace(out)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "⚠️") {
|
||||||
|
return nil // empty or a "reviewer failed" notice
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
var findings []finding
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
for _, loc := range pathLineRe.FindAllStringSubmatchIndex(out, -1) {
|
||||||
|
file := out[loc[2]:loc[3]]
|
||||||
|
lineStr := out[loc[4]:loc[5]]
|
||||||
|
ln, err := strconv.Atoi(lineStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := file + ":" + lineStr
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
li := strings.Count(out[:loc[0]], "\n") // line index of the match
|
||||||
|
findings = append(findings, finding{
|
||||||
|
file: file,
|
||||||
|
line: ln,
|
||||||
|
title: deriveTitle(lines, li),
|
||||||
|
detail: paragraphAt(lines, li),
|
||||||
|
})
|
||||||
|
if len(findings) >= maxFindingsPerLens {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveTitle finds the nearest preceding markdown heading / numbered item /
|
||||||
|
// bold lead-in at or above line li; failing that, the first sentence of the
|
||||||
|
// paragraph containing li.
|
||||||
|
func deriveTitle(lines []string, li int) string {
|
||||||
|
for i := li; i >= 0 && i < len(lines); i-- {
|
||||||
|
if t, ok := headingTitle(lines[i]); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paragraphTitle(lines, li)
|
||||||
|
}
|
||||||
|
|
||||||
|
// headingTitle returns the title carried by a markdown heading-like line:
|
||||||
|
// an ATX "#"/"##"/"###" heading, a numbered "1." lead-in, or a "**bold**"
|
||||||
|
// lead-in (optionally behind a list bullet). ok is false for ordinary lines.
|
||||||
|
func headingTitle(line string) (string, bool) {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if t == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(t, "#") {
|
||||||
|
h := strings.TrimSpace(strings.TrimLeft(t, "#"))
|
||||||
|
if h != "" {
|
||||||
|
return cleanHeading(h), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if m := numberedRe.FindStringSubmatch(t); m != nil {
|
||||||
|
if rest := strings.TrimSpace(m[1]); rest != "" {
|
||||||
|
return cleanHeading(rest), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Bold lead-in, possibly behind a "- "/"* "/"+ " bullet. Strip only the
|
||||||
|
// bullet marker (NOT the bold asterisks) before testing for "**".
|
||||||
|
b := t
|
||||||
|
for _, pfx := range []string{"- ", "* ", "+ "} {
|
||||||
|
if strings.HasPrefix(b, pfx) {
|
||||||
|
b = strings.TrimSpace(b[len(pfx):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(b, "**") {
|
||||||
|
if title := firstBold(b); title != "" {
|
||||||
|
return title, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanHeading normalizes an extracted heading into a concise title: prefer a
|
||||||
|
// leading "**bold**" span, otherwise trim a trailing colon and truncate.
|
||||||
|
func cleanHeading(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "**") {
|
||||||
|
if b := firstBold(s); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s = strings.TrimRight(s, ": ")
|
||||||
|
return truncate(s, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
// paragraphTitle falls back to the first sentence/line of the paragraph (the
|
||||||
|
// contiguous block of non-blank lines) containing line li, stripped of markdown
|
||||||
|
// list/emphasis markers.
|
||||||
|
func paragraphTitle(lines []string, li int) string {
|
||||||
|
if li < 0 || li >= len(lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start := li
|
||||||
|
for start > 0 && strings.TrimSpace(lines[start-1]) != "" {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
first := leadingSentence(stripMarkers(lines[start]))
|
||||||
|
return truncate(strings.TrimSpace(first), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingSentence returns text up to the first sentence terminator that is
|
||||||
|
// followed by a space (". " / "! " / "? "), so a dot inside a filename like
|
||||||
|
// "util.go" does not prematurely cut the sentence. With no internal terminator
|
||||||
|
// it returns the line minus any trailing terminal punctuation. (auto.go's
|
||||||
|
// firstSentence splits on any ".", which would mis-cut a "path:line" sentence.)
|
||||||
|
func leadingSentence(s string) string {
|
||||||
|
best := -1
|
||||||
|
for _, sep := range []string{". ", "! ", "? "} {
|
||||||
|
if i := strings.Index(s, sep); i >= 0 && (best == -1 || i < best) {
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best >= 0 {
|
||||||
|
return s[:best]
|
||||||
|
}
|
||||||
|
return strings.TrimRight(s, ".!?")
|
||||||
|
}
|
||||||
|
|
||||||
|
// paragraphAt returns the paragraph (contiguous non-blank lines) containing li,
|
||||||
|
// joined onto one line and truncated — used as the finding's detail context.
|
||||||
|
func paragraphAt(lines []string, li int) string {
|
||||||
|
if li < 0 || li >= len(lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start, end := li, li
|
||||||
|
for start > 0 && strings.TrimSpace(lines[start-1]) != "" {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for end < len(lines)-1 && strings.TrimSpace(lines[end+1]) != "" {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
if t := strings.TrimSpace(lines[i]); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return truncate(strings.Join(parts, " "), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripMarkers removes a leading list bullet / numbered marker and surrounding
|
||||||
|
// emphasis/backticks from a line, leaving plain text.
|
||||||
|
func stripMarkers(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
for _, pfx := range []string{"- ", "* ", "+ "} {
|
||||||
|
if strings.HasPrefix(s, pfx) {
|
||||||
|
s = strings.TrimSpace(s[len(pfx):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m := numberedRe.FindStringSubmatch(s); m != nil {
|
||||||
|
s = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
return strings.Trim(s, "*`# ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstBold returns the text inside the first "**...**" span of s, or "".
|
||||||
|
func firstBold(s string) string {
|
||||||
|
i := strings.Index(s, "**")
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := s[i+2:]
|
||||||
|
j := strings.Index(rest, "**")
|
||||||
|
if j < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(rest[:j])
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate shortens s to at most n runes, appending an ellipsis when cut.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:n])) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON marshals payload and POSTs it as application/json, attaching a Bearer
|
||||||
|
// token when non-empty. A non-2xx response is an error. The body is drained and
|
||||||
|
// closed so the connection can be reused.
|
||||||
|
func postJSON(client *http.Client, url, token string, payload any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("%s: status %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envAtoi reads an integer env var, returning 0 when unset or unparseable.
|
||||||
|
func envAtoi(name string) int {
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sampleLensMarkdown is a realistic lens output: a bold verdict lead-in, an ATX
|
||||||
|
// "###" heading anchoring one finding, a numbered + bold lead-in anchoring
|
||||||
|
// another, plus a duplicate "path:line" reference that must be deduped away.
|
||||||
|
const sampleLensMarkdown = "" +
|
||||||
|
"**Blocking issues found**\n" +
|
||||||
|
"\n" +
|
||||||
|
"### Swallowed error\n" +
|
||||||
|
"In `run/executor.go:166` the returned error is ignored, so a failed step is\n" +
|
||||||
|
"reported as success.\n" +
|
||||||
|
"\n" +
|
||||||
|
"1. **Missing nil check** — `compact/compactor.go:225` dereferences `cfg`\n" +
|
||||||
|
" which can be nil when the caller passes an empty config.\n" +
|
||||||
|
"\n" +
|
||||||
|
"See again run/executor.go:166 for the same root cause.\n"
|
||||||
|
|
||||||
|
func TestParseFindings(t *testing.T) {
|
||||||
|
got := parseFindings(Specialist{Name: "security"}, sampleLensMarkdown)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("parseFindings returned %d findings, want 2 (deduped): %+v", len(got), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got[0].file != "run/executor.go" || got[0].line != 166 {
|
||||||
|
t.Errorf("finding[0] location = %s:%d, want run/executor.go:166", got[0].file, got[0].line)
|
||||||
|
}
|
||||||
|
if got[0].title != "Swallowed error" {
|
||||||
|
t.Errorf("finding[0] title = %q, want %q (nearest ### heading)", got[0].title, "Swallowed error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got[1].file != "compact/compactor.go" || got[1].line != 225 {
|
||||||
|
t.Errorf("finding[1] location = %s:%d, want compact/compactor.go:225", got[1].file, got[1].line)
|
||||||
|
}
|
||||||
|
if got[1].title != "Missing nil check" {
|
||||||
|
t.Errorf("finding[1] title = %q, want %q (numbered bold lead-in)", got[1].title, "Missing nil check")
|
||||||
|
}
|
||||||
|
if got[1].detail == "" {
|
||||||
|
t.Error("finding[1] detail should carry the paragraph context, got empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFindings_ErroredLensYieldsNone(t *testing.T) {
|
||||||
|
notice := "⚠️ This reviewer failed to complete: context deadline exceeded (file.go:10)"
|
||||||
|
if f := parseFindings(Specialist{Name: "security"}, notice); len(f) != 0 {
|
||||||
|
t.Errorf("a failure-notice lens should yield no findings, got %+v", f)
|
||||||
|
}
|
||||||
|
if f := parseFindings(Specialist{Name: "security"}, " "); len(f) != 0 {
|
||||||
|
t.Errorf("empty output should yield no findings, got %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFindings_FallbackParagraphTitle(t *testing.T) {
|
||||||
|
md := "No material issues found.\n\nThe helper in src/util.go:42 returns the wrong code. Investigate.\n"
|
||||||
|
got := parseFindings(Specialist{Name: "correctness"}, md)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want 1 finding, got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].title != "The helper in src/util.go:42 returns the wrong code" {
|
||||||
|
t.Errorf("fallback title = %q, want first sentence of the paragraph", got[0].title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmit_PostsRunsAndReports(t *testing.T) {
|
||||||
|
var runs, reports int32
|
||||||
|
var runBody map[string]any
|
||||||
|
var reportBody []map[string]any
|
||||||
|
var sawAuth string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a := r.Header.Get("Authorization"); a != "" {
|
||||||
|
sawAuth = a
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(r.Body)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/runs":
|
||||||
|
atomic.AddInt32(&runs, 1)
|
||||||
|
_ = json.Unmarshal(data, &runBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"run_id":"x"}`))
|
||||||
|
case "/reports":
|
||||||
|
atomic.AddInt32(&reports, 1)
|
||||||
|
_ = json.Unmarshal(data, &reportBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"finding_ids":[1,2]}`))
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("GADFLY_FINDINGS_URL", srv.URL)
|
||||||
|
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-token")
|
||||||
|
t.Setenv("GADFLY_REPO", "owner/repo")
|
||||||
|
t.Setenv("GADFLY_PR", "7")
|
||||||
|
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
|
||||||
|
t.Setenv("GADFLY_PROVIDER", "")
|
||||||
|
|
||||||
|
results := []specialistResult{
|
||||||
|
{spec: Specialist{Name: "security", Title: "🔒 Security"}, out: sampleLensMarkdown, verdict: verdictBlocking},
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(results, 3*time.Second)
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&runs); got != 1 {
|
||||||
|
t.Fatalf("/runs received %d times, want exactly 1", got)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&reports); got != 1 {
|
||||||
|
t.Fatalf("/reports received %d times, want exactly 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sawAuth != "Bearer secret-token" {
|
||||||
|
t.Errorf("Authorization header = %q, want %q", sawAuth, "Bearer secret-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /runs body: exact field names + values ---
|
||||||
|
for _, k := range []string{"run_id", "repo", "pr", "model", "provider", "lenses", "duration_secs", "input_tokens", "output_tokens", "cost_usd"} {
|
||||||
|
if _, ok := runBody[k]; !ok {
|
||||||
|
t.Errorf("/runs body missing field %q (got keys %v)", k, keysOf(runBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runBody["run_id"] != "owner/repo#7:ollama-cloud/qwen3" {
|
||||||
|
t.Errorf("run_id = %v, want owner/repo#7:ollama-cloud/qwen3", runBody["run_id"])
|
||||||
|
}
|
||||||
|
if runBody["repo"] != "owner/repo" {
|
||||||
|
t.Errorf("repo = %v, want owner/repo", runBody["repo"])
|
||||||
|
}
|
||||||
|
if runBody["pr"] != float64(7) {
|
||||||
|
t.Errorf("pr = %v, want 7", runBody["pr"])
|
||||||
|
}
|
||||||
|
if runBody["model"] != "ollama-cloud/qwen3" {
|
||||||
|
t.Errorf("model = %v, want ollama-cloud/qwen3", runBody["model"])
|
||||||
|
}
|
||||||
|
if runBody["provider"] != "ollama-cloud" {
|
||||||
|
t.Errorf("provider = %v, want ollama-cloud", runBody["provider"])
|
||||||
|
}
|
||||||
|
if runBody["lenses"] != float64(1) {
|
||||||
|
t.Errorf("lenses = %v, want 1", runBody["lenses"])
|
||||||
|
}
|
||||||
|
if runBody["duration_secs"] != float64(3) {
|
||||||
|
t.Errorf("duration_secs = %v, want 3", runBody["duration_secs"])
|
||||||
|
}
|
||||||
|
if v, ok := runBody["input_tokens"]; !ok || v != nil {
|
||||||
|
t.Errorf("input_tokens = %v, want JSON null", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /reports body: exact field names + values ---
|
||||||
|
if len(reportBody) != 2 {
|
||||||
|
t.Fatalf("/reports array length = %d, want 2", len(reportBody))
|
||||||
|
}
|
||||||
|
for _, k := range []string{"repo", "pr", "lens", "file", "line", "title", "model", "provider", "run_id", "raw_severity", "detail"} {
|
||||||
|
if _, ok := reportBody[0][k]; !ok {
|
||||||
|
t.Errorf("/reports[0] missing field %q (got keys %v)", k, keysOf(reportBody[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reportBody[0]["lens"] != "security" {
|
||||||
|
t.Errorf("reports[0].lens = %v, want security", reportBody[0]["lens"])
|
||||||
|
}
|
||||||
|
if reportBody[0]["file"] != "run/executor.go" {
|
||||||
|
t.Errorf("reports[0].file = %v, want run/executor.go", reportBody[0]["file"])
|
||||||
|
}
|
||||||
|
if reportBody[0]["line"] != float64(166) {
|
||||||
|
t.Errorf("reports[0].line = %v, want 166", reportBody[0]["line"])
|
||||||
|
}
|
||||||
|
if reportBody[0]["raw_severity"] != "Blocking issues found" {
|
||||||
|
t.Errorf("reports[0].raw_severity = %v, want 'Blocking issues found'", reportBody[0]["raw_severity"])
|
||||||
|
}
|
||||||
|
if reportBody[0]["run_id"] != "owner/repo#7:ollama-cloud/qwen3" {
|
||||||
|
t.Errorf("reports[0].run_id = %v, want owner/repo#7:ollama-cloud/qwen3", reportBody[0]["run_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean "No material issues found" lens must NOT emit findings, even though
|
||||||
|
// its markdown contains path:line references (those are verification notes, not
|
||||||
|
// problems). /runs is still posted; /reports is not.
|
||||||
|
func TestEmit_SkipsCleanVerdictLens(t *testing.T) {
|
||||||
|
var runs, reports int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/runs":
|
||||||
|
atomic.AddInt32(&runs, 1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"run_id":"x"}`))
|
||||||
|
case "/reports":
|
||||||
|
atomic.AddInt32(&reports, 1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("GADFLY_FINDINGS_URL", srv.URL)
|
||||||
|
t.Setenv("GADFLY_REPO", "owner/repo")
|
||||||
|
t.Setenv("GADFLY_PR", "7")
|
||||||
|
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
|
||||||
|
|
||||||
|
// Clean verdict, but the markdown is full of path:line "verified X" notes.
|
||||||
|
cleanMarkdown := "No material issues found.\n\nVerified `run/executor.go:166` handles the error.\n"
|
||||||
|
results := []specialistResult{
|
||||||
|
{spec: Specialist{Name: "security"}, out: cleanMarkdown, verdict: verdictClean},
|
||||||
|
}
|
||||||
|
emit(results, time.Second)
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&runs); got != 1 {
|
||||||
|
t.Fatalf("/runs received %d times, want 1 (the run is still recorded)", got)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&reports); got != 0 {
|
||||||
|
t.Fatalf("/reports received %d times, want 0 (clean lens emits no findings)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmit_DisabledMakesNoRequests(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("GADFLY_FINDINGS_URL", "") // disabled
|
||||||
|
t.Setenv("GADFLY_REPO", "owner/repo")
|
||||||
|
t.Setenv("GADFLY_PR", "7")
|
||||||
|
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
|
||||||
|
|
||||||
|
results := []specialistResult{
|
||||||
|
{spec: Specialist{Name: "security"}, out: sampleLensMarkdown, verdict: verdictBlocking},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not panic and must not hit the server.
|
||||||
|
emit(results, time.Second)
|
||||||
|
emit(nil, 0)
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&hits); got != 0 {
|
||||||
|
t.Fatalf("telemetry disabled but server received %d requests, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]any) []string {
|
||||||
|
ks := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
ks = append(ks, k)
|
||||||
|
}
|
||||||
|
return ks
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reviewEngine runs a single agent pass against the checked-out repo and returns
|
||||||
|
// 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
|
||||||
|
// (specialist composition, recheck orchestration, consolidation, emit) is
|
||||||
|
// engine-agnostic. Two implementations:
|
||||||
|
//
|
||||||
|
// - majordomoEngine: the original path — a majordomo tool-using agent loop
|
||||||
|
// (read_file/grep/… over a sandboxed repoFS).
|
||||||
|
// - 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
|
||||||
|
// the final text.
|
||||||
|
//
|
||||||
|
// maxSteps is the tool-step budget for engines that have one (majordomo); the
|
||||||
|
// claude-code engine manages its own loop and ignores it.
|
||||||
|
type reviewEngine interface {
|
||||||
|
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// majordomoEngine drives the in-process majordomo agent over the repo sandbox.
|
||||||
|
type majordomoEngine struct {
|
||||||
|
mdl llm.Model
|
||||||
|
fsTools *repoFS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *majordomoEngine) runPass(ctx context.Context, system, task string, maxSteps int) (string, error) {
|
||||||
|
return runAgent(ctx, e.mdl, e.fsTools, system, task, maxSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudeCodeEngine reviews by shelling out to the `claude` CLI (Claude Code) in
|
||||||
|
// non-interactive print mode. Claude Code reads the checked-out tree with its
|
||||||
|
// own read tools (so it verifies findings against real code, like the agentic
|
||||||
|
// majordomo path), and we parse its final answer out of `--output-format json`.
|
||||||
|
//
|
||||||
|
// Auth is inherited from the environment: the default backend is a Pro/Max
|
||||||
|
// subscription via CLAUDE_CODE_OAUTH_TOKEN (no `--bare`). See README.
|
||||||
|
type claudeCodeEngine struct {
|
||||||
|
bin string // CLI binary (GADFLY_CLAUDE_BIN, default "claude")
|
||||||
|
model string // --model value ("" = CLI default)
|
||||||
|
repoDir string // cwd for the CLI, so its tools read the checked-out tree
|
||||||
|
permissionMode string // --permission-mode (default "plan": read-only, no edits)
|
||||||
|
allowedTools string // --allowedTools value, passed verbatim ("" = omit)
|
||||||
|
extraArgs []string // appended verbatim (GADFLY_CLAUDE_EXTRA_ARGS)
|
||||||
|
thinkingTokens int // MAX_THINKING_TOKENS for the subprocess; 0 = leave default
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxThinkingTokens is the extended-thinking budget used for a "claude-code/<model>:max"
|
||||||
|
// spec — Claude Code's high "ultrathink" tier. Set as MAX_THINKING_TOKENS on the
|
||||||
|
// subprocess; harmless (a no-op) if the CLI build doesn't honor it.
|
||||||
|
const maxThinkingTokens = 31999
|
||||||
|
|
||||||
|
// isClaudeCodeSpec reports whether a GADFLY_MODEL spec selects the claude-code
|
||||||
|
// engine: the bare id "claude-code" or a "claude-code/<model>" form.
|
||||||
|
func isClaudeCodeSpec(model string) bool {
|
||||||
|
m := strings.TrimSpace(model)
|
||||||
|
return m == "claude-code" || strings.HasPrefix(m, "claude-code/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newClaudeCodeEngine builds the engine from the GADFLY_MODEL spec and the
|
||||||
|
// optional GADFLY_CLAUDE_* overrides. The part after the slash in
|
||||||
|
// "claude-code/<model>[:<thinking>]" becomes --model (e.g. "claude-code/sonnet"
|
||||||
|
// → "sonnet"), with an optional thinking suffix: ":max" forces the high
|
||||||
|
// extended-thinking budget and ":<n>" sets a specific MAX_THINKING_TOKENS.
|
||||||
|
// GADFLY_CLAUDE_MODEL overrides the model id (the thinking suffix still applies).
|
||||||
|
// It does not verify the CLI is installed — a missing binary surfaces as a normal
|
||||||
|
// pass error (advisory, never fatal).
|
||||||
|
func newClaudeCodeEngine(spec, repoDir string) *claudeCodeEngine {
|
||||||
|
var model string
|
||||||
|
thinking := 0
|
||||||
|
if _, after, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
|
||||||
|
after = strings.TrimSpace(after)
|
||||||
|
// Optional ":<thinking>" suffix. Claude model aliases/ids contain no ":",
|
||||||
|
// so a colon unambiguously separates the thinking tier here.
|
||||||
|
if m, t, hasColon := strings.Cut(after, ":"); hasColon {
|
||||||
|
model = strings.TrimSpace(m)
|
||||||
|
thinking = parseThinking(strings.TrimSpace(t))
|
||||||
|
} else {
|
||||||
|
model = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("GADFLY_CLAUDE_MODEL")); env != "" {
|
||||||
|
model = env
|
||||||
|
}
|
||||||
|
return &claudeCodeEngine{
|
||||||
|
bin: envOr("GADFLY_CLAUDE_BIN", "claude"),
|
||||||
|
model: model,
|
||||||
|
repoDir: repoDir,
|
||||||
|
permissionMode: envOr("GADFLY_CLAUDE_PERMISSION_MODE", "plan"),
|
||||||
|
allowedTools: strings.TrimSpace(os.Getenv("GADFLY_CLAUDE_ALLOWED_TOOLS")),
|
||||||
|
extraArgs: strings.Fields(os.Getenv("GADFLY_CLAUDE_EXTRA_ARGS")),
|
||||||
|
thinkingTokens: thinking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseThinking maps a spec thinking suffix to a MAX_THINKING_TOKENS budget:
|
||||||
|
// "max" → maxThinkingTokens, a positive integer → itself, anything else → 0 (off).
|
||||||
|
func parseThinking(s string) int {
|
||||||
|
if strings.EqualFold(s, "max") {
|
||||||
|
return maxThinkingTokens
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// args assembles the `claude` argv for one pass. Factored out (and pure) so it
|
||||||
|
// can be unit-tested without invoking the CLI. The system prompt is layered on
|
||||||
|
// top of Claude Code's own via --append-system-prompt; the task is the -p
|
||||||
|
// prompt.
|
||||||
|
func (e *claudeCodeEngine) args(system, task string) []string {
|
||||||
|
a := []string{"-p", task, "--output-format", "json", "--append-system-prompt", system}
|
||||||
|
if e.model != "" {
|
||||||
|
a = append(a, "--model", e.model)
|
||||||
|
}
|
||||||
|
if e.permissionMode != "" {
|
||||||
|
a = append(a, "--permission-mode", e.permissionMode)
|
||||||
|
}
|
||||||
|
if e.allowedTools != "" {
|
||||||
|
a = append(a, "--allowedTools", e.allowedTools)
|
||||||
|
}
|
||||||
|
return append(a, e.extraArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudeResult is the subset of `claude --output-format json` we read.
|
||||||
|
type claudeResult struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, e.bin, e.args(system, task)...)
|
||||||
|
cmd.Dir = e.repoDir
|
||||||
|
cmd.Env = claudeEnv() // minimal env — don't hand GITEA_TOKEN et al. to the CLI
|
||||||
|
if e.thinkingTokens > 0 {
|
||||||
|
// Force an extended-thinking budget for this run (a "...:max" spec).
|
||||||
|
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
|
||||||
|
// 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
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
runErr := cmd.Run()
|
||||||
|
|
||||||
|
// A cancelled/timed-out run must surface as an error, never as whatever
|
||||||
|
// partial bytes the CLI flushed before it was killed.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("claude -p %v", ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
var res claudeResult
|
||||||
|
parsed := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &res) == nil
|
||||||
|
|
||||||
|
// Clean exit: trust the parsed JSON answer, and ONLY it — never fall back to
|
||||||
|
// the raw JSON envelope when the result is empty.
|
||||||
|
if runErr == nil && parsed {
|
||||||
|
if res.IsError {
|
||||||
|
return "", fmt.Errorf("claude reported error (%s): %s", res.Subtype, truncateForErr(res.Result))
|
||||||
|
}
|
||||||
|
if out := strings.TrimSpace(res.Result); out != "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("claude -p returned an empty result")
|
||||||
|
}
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
// Prefer the CLI's own structured error message when it gave one.
|
||||||
|
if parsed && res.IsError && strings.TrimSpace(res.Result) != "" {
|
||||||
|
return "", fmt.Errorf("claude reported error (%s): %s", res.Subtype, truncateForErr(res.Result))
|
||||||
|
}
|
||||||
|
detail := truncateForErr(stderr.String())
|
||||||
|
if detail == "" {
|
||||||
|
detail = truncateForErr(stdout.String())
|
||||||
|
}
|
||||||
|
if detail != "" {
|
||||||
|
return "", fmt.Errorf("claude -p failed: %v: %s", runErr, detail)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("claude -p failed: %v", runErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean exit but stdout wasn't the expected JSON envelope: degrade to the raw
|
||||||
|
// text so a CLI format change still yields a review instead of nothing.
|
||||||
|
if raw := strings.TrimSpace(stdout.String()); raw != "" {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("claude -p produced no parseable output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
|
||||||
|
// 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,
|
||||||
|
// 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.
|
||||||
|
func claudeEnv() []string {
|
||||||
|
keep := func(k string) bool {
|
||||||
|
switch k {
|
||||||
|
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(k, "LC_") ||
|
||||||
|
strings.HasPrefix(k, "CLAUDE_") ||
|
||||||
|
strings.HasPrefix(k, "ANTHROPIC_") ||
|
||||||
|
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
|
||||||
|
strings.HasPrefix(k, "NODE_") ||
|
||||||
|
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,
|
||||||
|
// cutting on a rune boundary so it never emits invalid UTF-8.
|
||||||
|
func truncateForErr(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
const max = 800
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := max
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return s[:cut] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOr returns the env var value or a default when unset/blank.
|
||||||
|
func envOr(name, def string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsClaudeCodeSpec(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"claude-code": true,
|
||||||
|
"claude-code/sonnet": true,
|
||||||
|
"claude-code/opus": true,
|
||||||
|
"claude-code/claude-opus-4-8": true,
|
||||||
|
" claude-code ": true, // trimmed
|
||||||
|
"qwen3-coder:480b-cloud": false,
|
||||||
|
"claude-code-extra": false, // not the bare id, not a "/" form
|
||||||
|
"sonnet": false,
|
||||||
|
"": false,
|
||||||
|
}
|
||||||
|
for spec, want := range cases {
|
||||||
|
if got := isClaudeCodeSpec(spec); got != want {
|
||||||
|
t.Errorf("isClaudeCodeSpec(%q) = %v, want %v", spec, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClaudeCodeEngineModel(t *testing.T) {
|
||||||
|
// model derived from the spec's "/<model>" suffix
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "")
|
||||||
|
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "sonnet" {
|
||||||
|
t.Errorf("model = %q, want sonnet", e.model)
|
||||||
|
}
|
||||||
|
// bare spec → CLI default (no --model)
|
||||||
|
if e := newClaudeCodeEngine("claude-code", "/repo"); e.model != "" {
|
||||||
|
t.Errorf("model = %q, want empty for bare spec", e.model)
|
||||||
|
}
|
||||||
|
// GADFLY_CLAUDE_MODEL overrides the spec suffix
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "opus")
|
||||||
|
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "opus" {
|
||||||
|
t.Errorf("model = %q, want opus (env override)", e.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCodeEngineDefaults(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_CLAUDE_BIN", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
|
||||||
|
e := newClaudeCodeEngine("claude-code", "/repo")
|
||||||
|
if e.bin != "claude" {
|
||||||
|
t.Errorf("bin = %q, want claude", e.bin)
|
||||||
|
}
|
||||||
|
if e.permissionMode != "plan" {
|
||||||
|
t.Errorf("permissionMode = %q, want plan", e.permissionMode)
|
||||||
|
}
|
||||||
|
if e.repoDir != "/repo" {
|
||||||
|
t.Errorf("repoDir = %q, want /repo", e.repoDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// argAfter returns the value following flag in args, or "" if absent.
|
||||||
|
func argAfter(args []string, flag string) string {
|
||||||
|
if i := slices.Index(args, flag); i >= 0 && i+1 < len(args) {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCodeArgs(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "Read,Grep,Glob")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "--max-turns 30")
|
||||||
|
e := newClaudeCodeEngine("claude-code/sonnet", "/repo")
|
||||||
|
args := e.args("SYS-PROMPT", "TASK-PROMPT")
|
||||||
|
|
||||||
|
// task is the -p value; json output; system appended; model + policy present.
|
||||||
|
if argAfter(args, "-p") != "TASK-PROMPT" {
|
||||||
|
t.Errorf("-p = %q, want TASK-PROMPT", argAfter(args, "-p"))
|
||||||
|
}
|
||||||
|
if argAfter(args, "--output-format") != "json" {
|
||||||
|
t.Errorf("--output-format = %q, want json", argAfter(args, "--output-format"))
|
||||||
|
}
|
||||||
|
if argAfter(args, "--append-system-prompt") != "SYS-PROMPT" {
|
||||||
|
t.Errorf("--append-system-prompt = %q, want SYS-PROMPT", argAfter(args, "--append-system-prompt"))
|
||||||
|
}
|
||||||
|
if argAfter(args, "--model") != "sonnet" {
|
||||||
|
t.Errorf("--model = %q, want sonnet", argAfter(args, "--model"))
|
||||||
|
}
|
||||||
|
if argAfter(args, "--permission-mode") != "plan" {
|
||||||
|
t.Errorf("--permission-mode = %q, want plan", argAfter(args, "--permission-mode"))
|
||||||
|
}
|
||||||
|
if argAfter(args, "--allowedTools") != "Read,Grep,Glob" {
|
||||||
|
t.Errorf("--allowedTools = %q, want Read,Grep,Glob", argAfter(args, "--allowedTools"))
|
||||||
|
}
|
||||||
|
// extra args appended verbatim (split on whitespace)
|
||||||
|
if !strings.Contains(strings.Join(args, " "), "--max-turns 30") {
|
||||||
|
t.Errorf("extra args not appended: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCodeArgsBareModelOmitsFlag(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "") // omit when blank
|
||||||
|
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
|
||||||
|
e := newClaudeCodeEngine("claude-code", "/repo")
|
||||||
|
args := e.args("s", "t")
|
||||||
|
if slices.Contains(args, "--model") {
|
||||||
|
t.Errorf("--model should be omitted for a bare claude-code spec: %v", args)
|
||||||
|
}
|
||||||
|
if slices.Contains(args, "--allowedTools") {
|
||||||
|
t.Errorf("--allowedTools should be omitted when blank: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeEnvFilters(t *testing.T) {
|
||||||
|
t.Setenv("GITEA_TOKEN", "secret-gitea")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "secret-ollama")
|
||||||
|
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
|
||||||
|
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
|
||||||
|
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "keep-claude")
|
||||||
|
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic")
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "keep-knob")
|
||||||
|
|
||||||
|
env := claudeEnv()
|
||||||
|
has := func(k string) bool {
|
||||||
|
for _, kv := range env {
|
||||||
|
if strings.HasPrefix(kv, k+"=") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// kept: the CLI's auth + its own knobs + PATH
|
||||||
|
for _, k := range []string{"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "GADFLY_CLAUDE_MODEL", "PATH"} {
|
||||||
|
if !has(k) {
|
||||||
|
t.Errorf("claudeEnv dropped %s, but it should be kept", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// dropped: the runner's secrets the CLI doesn't need
|
||||||
|
for _, k := range []string{"GITEA_TOKEN", "OLLAMA_API_KEY", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN"} {
|
||||||
|
if has(k) {
|
||||||
|
t.Errorf("claudeEnv leaked %s into the subprocess env", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateForErrRuneSafe(t *testing.T) {
|
||||||
|
// 900 multibyte runes (3 bytes each) -> well over the 800-byte cap; the cut
|
||||||
|
// must land on a rune boundary so the result stays valid UTF-8.
|
||||||
|
s := strings.Repeat("€", 900)
|
||||||
|
got := truncateForErr(s)
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Fatalf("truncateForErr produced invalid UTF-8")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "…") {
|
||||||
|
t.Fatalf("truncateForErr should append an ellipsis when truncating")
|
||||||
|
}
|
||||||
|
// short strings pass through untouched
|
||||||
|
if truncateForErr(" hi ") != "hi" {
|
||||||
|
t.Fatalf("truncateForErr should trim and pass short strings through")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubClaude writes an executable shell stub that prints body and exits code,
|
||||||
|
// and returns an engine pointed at it.
|
||||||
|
func stubClaude(t *testing.T, body string, code int) *claudeCodeEngine {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/claude-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 &claudeCodeEngine{bin: path, repoDir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||||
|
func itoa(i int) string { return string(rune('0' + i)) } // single-digit exit codes only
|
||||||
|
|
||||||
|
func TestRunPassCleanResult(t *testing.T) {
|
||||||
|
e := stubClaude(t, `{"result":"REVIEW TEXT","is_error":false}`, 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 TestRunPassEmptyResultIsError(t *testing.T) {
|
||||||
|
// JSON parses, exit 0, but result empty: must NOT return the raw JSON blob.
|
||||||
|
e := stubClaude(t, `{"result":"","is_error":false}`, 0)
|
||||||
|
out, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("empty result should be an error, got out=%q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "{") {
|
||||||
|
t.Fatalf("empty result must not leak raw JSON, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPassIsErrorFlag(t *testing.T) {
|
||||||
|
e := stubClaude(t, `{"result":"boom","is_error":true,"subtype":"error_max_turns"}`, 0)
|
||||||
|
_, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "claude reported error") {
|
||||||
|
t.Fatalf("is_error should surface as an error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPassNonZeroNoJSON(t *testing.T) {
|
||||||
|
e := stubClaude(t, "fatal: auth failed", 1)
|
||||||
|
_, err := e.runPass(context.Background(), "sys", "task", 0)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "claude -p failed") {
|
||||||
|
t.Fatalf("non-zero exit should error with detail, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCodeThinking(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "")
|
||||||
|
cases := []struct {
|
||||||
|
spec string
|
||||||
|
wantModel string
|
||||||
|
wantThink int
|
||||||
|
}{
|
||||||
|
{"claude-code/opus", "opus", 0},
|
||||||
|
{"claude-code/opus:max", "opus", maxThinkingTokens},
|
||||||
|
{"claude-code/sonnet:20000", "sonnet", 20000},
|
||||||
|
{"claude-code/opus:bogus", "opus", 0}, // unrecognized suffix -> off
|
||||||
|
{"claude-code", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
e := newClaudeCodeEngine(c.spec, "/repo")
|
||||||
|
if e.model != c.wantModel || e.thinkingTokens != c.wantThink {
|
||||||
|
t.Errorf("newClaudeCodeEngine(%q) = (model %q, think %d), want (%q, %d)",
|
||||||
|
c.spec, e.model, e.thinkingTokens, c.wantModel, c.wantThink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCodeThinkingEnvOverrideKeepsSuffix(t *testing.T) {
|
||||||
|
// GADFLY_CLAUDE_MODEL overrides the model id, but the :max thinking from the
|
||||||
|
// spec still applies.
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "claude-opus-4-8")
|
||||||
|
e := newClaudeCodeEngine("claude-code/opus:max", "/repo")
|
||||||
|
if e.model != "claude-opus-4-8" {
|
||||||
|
t.Errorf("model = %q, want claude-opus-4-8 (env override)", e.model)
|
||||||
|
}
|
||||||
|
if e.thinkingTokens != maxThinkingTokens {
|
||||||
|
t.Errorf("thinkingTokens = %d, want %d (suffix still applies)", e.thinkingTokens, maxThinkingTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPassInjectsThinkingTokens verifies the engine actually puts
|
||||||
|
// MAX_THINKING_TOKENS into the subprocess env for a ":max" spec (and not for a
|
||||||
|
// plain spec). The stub echoes the value it received back as the result.
|
||||||
|
func TestRunPassInjectsThinkingTokens(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_CLAUDE_MODEL", "")
|
||||||
|
t.Setenv("MAX_THINKING_TOKENS", "") // not in the test's own env
|
||||||
|
dir := t.TempDir()
|
||||||
|
stub := dir + "/claude-stub.sh"
|
||||||
|
// Report the env var the CLI was launched with.
|
||||||
|
script := "#!/bin/sh\nprintf '{\"result\":\"MTT=%s\",\"is_error\":false}' \"${MAX_THINKING_TOKENS:-unset}\"\n"
|
||||||
|
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxEng := newClaudeCodeEngine("claude-code/opus:max", dir)
|
||||||
|
maxEng.bin = stub
|
||||||
|
if out, err := maxEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=31999" {
|
||||||
|
t.Fatalf(":max run: got (%q, %v), want (MTT=31999, nil)", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plainEng := newClaudeCodeEngine("claude-code/opus", dir)
|
||||||
|
plainEng.bin = stub
|
||||||
|
if out, err := plainEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=unset" {
|
||||||
|
t.Fatalf("plain run: got (%q, %v), want (MTT=unset, nil)", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trackingModel wraps a real llm.Model and records the maximum number of
|
||||||
|
// concurrent Generate calls, holding each call open briefly so overlap is
|
||||||
|
// observable. The concurrency tracking + sleep happen OUTSIDE the wrapped
|
||||||
|
// model's call, so (unlike the fake provider, which holds its mutex while
|
||||||
|
// invoking its default func) this measures genuine caller-side concurrency.
|
||||||
|
type trackingModel struct {
|
||||||
|
inner llm.Model
|
||||||
|
onEnter func()
|
||||||
|
onExit func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingModel) Generate(ctx context.Context, req llm.Request, opts ...llm.Option) (*llm.Response, error) {
|
||||||
|
m.onEnter()
|
||||||
|
defer m.onExit()
|
||||||
|
time.Sleep(40 * time.Millisecond) // overlap window: concurrent lenses pile up here
|
||||||
|
return m.inner.Generate(ctx, req, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingModel) Stream(ctx context.Context, req llm.Request, opts ...llm.Option) (llm.Stream, error) {
|
||||||
|
return m.inner.Stream(ctx, req, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *trackingModel) Capabilities() llm.Capabilities { return m.inner.Capabilities() }
|
||||||
|
|
||||||
|
// peakTrackingModel returns a model that replies with a clean verdict (so the
|
||||||
|
// recheck pass is skipped — one Generate per lens) and a getter for the peak
|
||||||
|
// number of concurrent Generate calls observed.
|
||||||
|
func peakTrackingModel(t *testing.T) (llm.Model, func() int) {
|
||||||
|
t.Helper()
|
||||||
|
p := fake.New("fake", fake.WithDefault(func(_ string, _ llm.Request) fake.Step {
|
||||||
|
return fake.Reply("No material issues found.")
|
||||||
|
}))
|
||||||
|
inner, err := p.Model("mock")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var inFlight, peak int
|
||||||
|
enter := func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
inFlight++
|
||||||
|
if inFlight > peak {
|
||||||
|
peak = inFlight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit := func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
inFlight--
|
||||||
|
}
|
||||||
|
return &trackingModel{inner: inner, onEnter: enter, onExit: exit},
|
||||||
|
func() int { mu.Lock(); defer mu.Unlock(); return peak }
|
||||||
|
}
|
||||||
|
|
||||||
|
func threeLenses() []Specialist {
|
||||||
|
return []Specialist{
|
||||||
|
{Name: "security", Title: "🔒 Security", Focus: "x"},
|
||||||
|
{Name: "correctness", Title: "🎯 Correctness", Focus: "y"},
|
||||||
|
{Name: "error-handling", Title: "🧯 Errors", Focus: "z"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOrderClean(t *testing.T, specs []Specialist, results []specialistResult) {
|
||||||
|
t.Helper()
|
||||||
|
if len(results) != len(specs) {
|
||||||
|
t.Fatalf("got %d results, want %d", len(results), len(specs))
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if r.spec.Name != specs[i].Name {
|
||||||
|
t.Errorf("result %d is %q, want %q (order must follow the specialist list, not finish order)", i, r.spec.Name, specs[i].Name)
|
||||||
|
}
|
||||||
|
if r.errored {
|
||||||
|
t.Errorf("lens %q unexpectedly errored", r.spec.Name)
|
||||||
|
}
|
||||||
|
if r.verdict != verdictClean {
|
||||||
|
t.Errorf("lens %q verdict = %v, want clean", r.spec.Name, r.verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSpecialists_FansOut: with GADFLY_LENS_CONCURRENCY=3 all three lenses run
|
||||||
|
// at once (peak == 3), and results still come back in specialist order.
|
||||||
|
func TestRunSpecialists_FansOut(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_LENS_CONCURRENCY", "3")
|
||||||
|
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "")
|
||||||
|
mdl, peak := peakTrackingModel(t)
|
||||||
|
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
specs := threeLenses()
|
||||||
|
|
||||||
|
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
|
||||||
|
|
||||||
|
if got := peak(); got != 3 {
|
||||||
|
t.Errorf("peak concurrent lenses = %d, want 3", got)
|
||||||
|
}
|
||||||
|
assertOrderClean(t, specs, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSpecialists_SequentialByDefault: an unset GADFLY_LENS_CONCURRENCY keeps
|
||||||
|
// the suite strictly serial (peak == 1) — the historical behavior.
|
||||||
|
func TestRunSpecialists_SequentialByDefault(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_LENS_CONCURRENCY", "")
|
||||||
|
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "")
|
||||||
|
mdl, peak := peakTrackingModel(t)
|
||||||
|
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
specs := threeLenses()
|
||||||
|
|
||||||
|
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
|
||||||
|
|
||||||
|
if got := peak(); got != 1 {
|
||||||
|
t.Errorf("peak concurrent lenses = %d, want 1 (sequential by default)", got)
|
||||||
|
}
|
||||||
|
assertOrderClean(t, specs, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSpecialists_PerProviderFanOut: a per-provider override fans this model's
|
||||||
|
// lenses out even when the scalar default is serial — the model's lane (from
|
||||||
|
// GADFLY_MODEL's prefix) is matched against GADFLY_PROVIDER_LENS_CONCURRENCY.
|
||||||
|
func TestRunSpecialists_PerProviderFanOut(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_MODEL", "m1/qwen3:14b")
|
||||||
|
t.Setenv("GADFLY_LENS_CONCURRENCY", "1") // scalar default: serial
|
||||||
|
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "m1=3") // but the m1 lane fans out
|
||||||
|
mdl, peak := peakTrackingModel(t)
|
||||||
|
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
specs := threeLenses()
|
||||||
|
|
||||||
|
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
|
||||||
|
|
||||||
|
if got := peak(); got != 3 {
|
||||||
|
t.Errorf("peak concurrent lenses = %d, want 3 (m1 per-provider override)", got)
|
||||||
|
}
|
||||||
|
assertOrderClean(t, specs, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLensConcurrency covers the resolution matrix: scalar default, scalar
|
||||||
|
// override, and per-provider override keyed by the model's resolved lane (same
|
||||||
|
// lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY).
|
||||||
|
func TestLensConcurrency(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
provider string
|
||||||
|
scalar string
|
||||||
|
provMap string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"default", "", "", "", "", 1},
|
||||||
|
{"scalar", "", "", "4", "", 4},
|
||||||
|
{"scalar invalid falls back to default", "", "", "nope", "", 1},
|
||||||
|
{"bare id uses GADFLY_PROVIDER lane", "qwen3-coder:480b-cloud", "ollama-cloud", "1", "ollama-cloud=3", 3},
|
||||||
|
{"bare id with no provider defaults to ollama-cloud lane", "qwen3-coder:480b-cloud", "", "1", "ollama-cloud=7", 7},
|
||||||
|
{"prefixed spec uses its prefix lane", "m1/qwen3:14b", "", "1", "m1=2,ollama-cloud=3", 2},
|
||||||
|
{"no lane match falls back to scalar", "m5/qwen3.6:35b-mlx", "", "5", "m1=2", 5},
|
||||||
|
{"override wins over scalar", "ollama-cloud/x", "", "9", "ollama-cloud=2", 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_MODEL", tt.model)
|
||||||
|
t.Setenv("GADFLY_PROVIDER", tt.provider)
|
||||||
|
t.Setenv("GADFLY_LENS_CONCURRENCY", tt.scalar)
|
||||||
|
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", tt.provMap)
|
||||||
|
if got := lensConcurrency(); got != tt.want {
|
||||||
|
t.Errorf("lensConcurrency() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-28
@@ -20,8 +20,19 @@
|
|||||||
//
|
//
|
||||||
// Inputs (env):
|
// Inputs (env):
|
||||||
//
|
//
|
||||||
// OLLAMA_API_KEY Ollama Cloud bearer key (required).
|
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
|
||||||
// GADFLY_MODEL model id, e.g. "qwen3-coder:480b-cloud" (required).
|
// alias / failover chain (required). A bare id is
|
||||||
|
// prefixed with GADFLY_PROVIDER.
|
||||||
|
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
|
||||||
|
// e.g. "ollama" for a local daemon, "openai", …).
|
||||||
|
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
|
||||||
|
// servers, remote Ollama, gateways). See model.go.
|
||||||
|
// GADFLY_API_KEY provider key; optional — falls back to the provider's
|
||||||
|
// standard env (OLLAMA_API_KEY / OPENAI_API_KEY /
|
||||||
|
// ANTHROPIC_API_KEY / GOOGLE_API_KEY|GEMINI_API_KEY).
|
||||||
|
// GADFLY_SPECIALISTS csv of review lenses, "all", or "auto" (see specialists.go).
|
||||||
|
// GADFLY_SELECTOR_MODEL model that picks lenses in "auto" mode (default: review model).
|
||||||
|
// GADFLY_WORKER_MODEL cheap model for the delegate_investigation tool (unset = off).
|
||||||
// GADFLY_REPO_DIR path to the checked-out repo (required; the FS sandbox root).
|
// GADFLY_REPO_DIR path to the checked-out repo (required; the FS sandbox root).
|
||||||
// GADFLY_DIFF_FILE path to a file holding the full unified diff (required).
|
// GADFLY_DIFF_FILE path to a file holding the full unified diff (required).
|
||||||
// GADFLY_SYSTEM_FILE path to the reviewer system prompt (required).
|
// GADFLY_SYSTEM_FILE path to the reviewer system prompt (required).
|
||||||
@@ -35,6 +46,15 @@
|
|||||||
// GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on).
|
// GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on).
|
||||||
// GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16).
|
// GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16).
|
||||||
// GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300).
|
// GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300).
|
||||||
|
// GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently within this
|
||||||
|
// model (optional, default 1 = sequential). Total in-flight
|
||||||
|
// model requests ≈ this × entrypoint.sh's per-provider model
|
||||||
|
// concurrency, so keep the product within the backend's budget.
|
||||||
|
// GADFLY_PROVIDER_LENS_CONCURRENCY per-provider override for the above, as a
|
||||||
|
// "provider=N,provider=N" map keyed by the SAME provider
|
||||||
|
// lanes as GADFLY_PROVIDER_CONCURRENCY (e.g.
|
||||||
|
// "ollama-cloud=3,m1=1"). Wins over GADFLY_LENS_CONCURRENCY
|
||||||
|
// for the model's provider; falls back to it otherwise.
|
||||||
// GADFLY_MAX_DIFF_CHARS diff chars embedded in the prompt (optional, default 60000;
|
// GADFLY_MAX_DIFF_CHARS diff chars embedded in the prompt (optional, default 60000;
|
||||||
// the full diff is always available via the get_diff tool).
|
// the full diff is always available via the get_diff tool).
|
||||||
//
|
//
|
||||||
@@ -50,23 +70,33 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMaxSteps = 24
|
defaultMaxSteps = 24
|
||||||
|
// defaultTimeoutSecs is the deadline for EACH specialist's passes (review +
|
||||||
|
// recheck). It is per-lens, not shared across the suite, so one slow lens
|
||||||
|
// (e.g. a big local model) can't starve the others. Slow local models may
|
||||||
|
// need this raised (and a higher job timeout to match the suite total).
|
||||||
defaultTimeoutSecs = 300
|
defaultTimeoutSecs = 300
|
||||||
defaultMaxDiffChars = 60000
|
defaultMaxDiffChars = 60000
|
||||||
|
// autoSelectTimeout bounds the dynamic specialist-selection call.
|
||||||
|
autoSelectTimeout = 120 * time.Second
|
||||||
// defaultWrapUpReserve is how many steps before the cap the agent is told
|
// defaultWrapUpReserve is how many steps before the cap the agent is told
|
||||||
// to stop investigating and write its final answer. Reserving a margin is
|
// to stop investigating and write its final answer. Reserving a margin is
|
||||||
// what keeps a thorough reviewer from spending its whole budget on tool
|
// what keeps a thorough reviewer from spending its whole budget on tool
|
||||||
// calls and then hard-failing with "max steps reached without a final
|
// calls and then hard-failing with "max steps reached without a final
|
||||||
// answer" — it always has a few steps left to wrap up.
|
// answer" — it always has a few steps left to wrap up.
|
||||||
defaultWrapUpReserve = 4
|
defaultWrapUpReserve = 4
|
||||||
|
// defaultLensConcurrency is how many specialist lenses run at once within a
|
||||||
|
// single model. 1 keeps the suite sequential (the historical behavior);
|
||||||
|
// higher values overlap the independent per-lens passes. See runSpecialists.
|
||||||
|
defaultLensConcurrency = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// wrapUpInstruction is steered into a running agent once it comes within the
|
// wrapUpInstruction is steered into a running agent once it comes within the
|
||||||
@@ -92,16 +122,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
apiKey := os.Getenv("OLLAMA_API_KEY")
|
start := time.Now()
|
||||||
if apiKey == "" {
|
|
||||||
return errors.New("OLLAMA_API_KEY is required")
|
|
||||||
}
|
|
||||||
model := os.Getenv("GADFLY_MODEL")
|
|
||||||
repoDir := os.Getenv("GADFLY_REPO_DIR")
|
repoDir := os.Getenv("GADFLY_REPO_DIR")
|
||||||
diffFile := os.Getenv("GADFLY_DIFF_FILE")
|
diffFile := os.Getenv("GADFLY_DIFF_FILE")
|
||||||
systemFile := os.Getenv("GADFLY_SYSTEM_FILE")
|
systemFile := os.Getenv("GADFLY_SYSTEM_FILE")
|
||||||
if model == "" || repoDir == "" || diffFile == "" || systemFile == "" {
|
if repoDir == "" || diffFile == "" || systemFile == "" {
|
||||||
return errors.New("GADFLY_MODEL, GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required")
|
return errors.New("GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required")
|
||||||
}
|
}
|
||||||
|
|
||||||
diffBytes, err := os.ReadFile(diffFile)
|
diffBytes, err := os.ReadFile(diffFile)
|
||||||
@@ -123,39 +149,186 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
mdl, err := ollama.Cloud(ollama.WithToken(apiKey)).Model(model)
|
// Resolve the review engine. The claude-code engine shells out to the
|
||||||
if err != nil {
|
// `claude` CLI (its own repo tools); every other spec is a majordomo model.
|
||||||
return fmt.Errorf("build model %q: %w", model, err)
|
// auto-selection and the delegate worker are majordomo-only — with
|
||||||
|
// claude-code they're skipped (Claude Code does its own legwork).
|
||||||
|
ccSpec := isClaudeCodeSpec(os.Getenv("GADFLY_MODEL"))
|
||||||
|
var eng reviewEngine
|
||||||
|
if ccSpec {
|
||||||
|
eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root)
|
||||||
|
} else {
|
||||||
|
mdl, merr := resolveModel()
|
||||||
|
if merr != nil {
|
||||||
|
return fmt.Errorf("resolve model: %w", merr)
|
||||||
|
}
|
||||||
|
// Optional cheap worker for delegate_investigation. Non-fatal: a bad
|
||||||
|
// worker spec just disables delegation rather than sinking the review.
|
||||||
|
if worker, werr := resolveWorkerModel(); werr != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: worker model disabled:", werr)
|
||||||
|
} else if worker != nil {
|
||||||
|
fsTools.worker = worker
|
||||||
|
}
|
||||||
|
eng = &majordomoEngine{mdl: mdl, fsTools: fsTools}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
specialists, registry, auto, serrs := resolveSpecialists(repoDir)
|
||||||
|
for _, e := range serrs {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly:", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
|
||||||
|
// Majordomo-only — the selector is an llm.Model.
|
||||||
|
if auto {
|
||||||
|
if ccSpec {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite")
|
||||||
|
specialists = suiteFromRegistry(registry, defaultSuite)
|
||||||
|
} else {
|
||||||
|
selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl)
|
||||||
|
if serr != nil {
|
||||||
|
return fmt.Errorf("resolve selector model: %w", serr)
|
||||||
|
}
|
||||||
|
selCtx, cancel := context.WithTimeout(context.Background(), autoSelectTimeout)
|
||||||
|
picked, aerr := autoSelectSpecialists(selCtx, selector, os.Getenv("GADFLY_TITLE"), os.Getenv("GADFLY_BODY"), diff, registry)
|
||||||
|
cancel()
|
||||||
|
if aerr != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: auto-select failed; falling back to the default suite:", aerr)
|
||||||
|
specialists = suiteFromRegistry(registry, defaultSuite)
|
||||||
|
} else {
|
||||||
|
specialists = picked
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: auto-selected specialists:", specialistNamesOf(specialists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(specialists) == 0 {
|
||||||
|
return errors.New("no specialists resolved to run")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := string(systemBytes)
|
||||||
|
task := buildTask(diff)
|
||||||
|
results := runSpecialists(eng, base, specialists, task, diff)
|
||||||
|
|
||||||
|
fmt.Println(renderConsolidated(results))
|
||||||
|
|
||||||
|
// Optional, best-effort telemetry. OFF unless GADFLY_FINDINGS_URL is set;
|
||||||
|
// any failure is logged to stderr and never affects stdout or the exit code.
|
||||||
|
emit(results, time.Since(start))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSpecialists reviews the diff through each lens and returns the results in
|
||||||
|
// the SAME order as specialists, regardless of finish order. Up to
|
||||||
|
// GADFLY_LENS_CONCURRENCY lenses run concurrently; the default of 1 keeps the
|
||||||
|
// suite sequential, exactly as before. Each lens already runs under its own
|
||||||
|
// per-lens timeout (reviewWithSpecialist), so concurrency simply overlaps those
|
||||||
|
// independent passes — and because reviewWithSpecialist builds a fresh toolbox
|
||||||
|
// per pass and the lenses only read the immutable repoFS, they share no mutable
|
||||||
|
// state. Results are stored by index so the consolidated comment keeps the
|
||||||
|
// configured lens order.
|
||||||
|
//
|
||||||
|
// Caution: this fans out WITHIN one model. It multiplies with entrypoint.sh's
|
||||||
|
// per-provider model concurrency, so total concurrent backend requests ≈
|
||||||
|
// (models at once) × (lenses at once). To fan lenses out without oversubscribing
|
||||||
|
// the backend, run models one at a time (provider lane cap 1) and raise this.
|
||||||
|
func runSpecialists(eng reviewEngine, base string, specialists []Specialist, task, diff string) []specialistResult {
|
||||||
|
results := make([]specialistResult, len(specialists))
|
||||||
|
|
||||||
|
// Optional live status board: publishes this model's per-lens progress to a
|
||||||
|
// file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE
|
||||||
|
// is set, so plain runs are unaffected.
|
||||||
|
sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists)
|
||||||
|
|
||||||
|
conc := min(lensConcurrency(), len(specialists))
|
||||||
|
|
||||||
|
sem := make(chan struct{}, conc)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, sp := range specialists {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{} // blocks once `conc` lenses are already in flight
|
||||||
|
go func(i int, sp Specialist) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
// A panic in one lens must not crash the whole binary (which would
|
||||||
|
// kill every other lens's output) or leave this lens stuck at
|
||||||
|
// "running" on the status board. Recover, record it as an errored
|
||||||
|
// result, and mark the lens finished so the board can complete.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
results[i] = specialistResult{spec: sp, out: fmt.Sprintf("⚠️ This reviewer panicked: %v", r), verdict: verdictUnknown, errored: true}
|
||||||
|
sw.set(sp.Name, lensFinished, "", true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sw.set(sp.Name, lensRunning, "", false)
|
||||||
|
out, errored := reviewWithSpecialist(eng, base, sp, task, diff)
|
||||||
|
v := parseVerdict(out)
|
||||||
|
results[i] = specialistResult{spec: sp, out: out, verdict: v, errored: errored}
|
||||||
|
sw.set(sp.Name, lensFinished, v.label(), errored)
|
||||||
|
}(i, sp)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// lensConcurrency resolves how many specialist lenses run at once for THIS run's
|
||||||
|
// model. It mirrors entrypoint.sh's per-provider MODEL concurrency: a
|
||||||
|
// per-provider override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...")
|
||||||
|
// wins for the model's provider, otherwise the GADFLY_LENS_CONCURRENCY scalar
|
||||||
|
// (default 1). The provider is resolved by modelProvider() — the SAME lane rule
|
||||||
|
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY — so e.g.
|
||||||
|
// "ollama-cloud=3,m1=1" fans cloud lenses out while keeping a slow local box
|
||||||
|
// serial, exactly the way the model map does for whole models.
|
||||||
|
func lensConcurrency() int {
|
||||||
|
if n, ok := providerOverride("GADFLY_PROVIDER_LENS_CONCURRENCY", modelProvider()); ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return envInt("GADFLY_LENS_CONCURRENCY", defaultLensConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerOverride parses a "provider=N,provider=N" env map and returns the
|
||||||
|
// value for provider when present and valid (>0). Mirrors entrypoint.sh's
|
||||||
|
// provider_cap lookup so the two concurrency maps share one syntax.
|
||||||
|
func providerOverride(envName, provider string) (int, bool) {
|
||||||
|
for _, item := range strings.Split(os.Getenv(envName), ",") {
|
||||||
|
k, v, ok := strings.Cut(item, "=")
|
||||||
|
if !ok || strings.TrimSpace(k) != provider {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewWithSpecialist runs one lens end-to-end under its OWN timeout, so a slow
|
||||||
|
// model on one lens can't starve the others: a review pass under the
|
||||||
|
// specialist's composed prompt, then the shared adversarial recheck pass. The
|
||||||
|
// returned bool is true when the review pass failed (rendered as an inline
|
||||||
|
// notice — advisory; one lens failing never sinks the others or the job).
|
||||||
|
func reviewWithSpecialist(eng reviewEngine, base string, sp Specialist, task, diff string) (string, bool) {
|
||||||
timeout := time.Duration(envInt("GADFLY_TIMEOUT_SECS", defaultTimeoutSecs)) * time.Second
|
timeout := time.Duration(envInt("GADFLY_TIMEOUT_SECS", defaultTimeoutSecs)) * time.Second
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Pass 1 — review: produce the draft.
|
draft, err := eng.runPass(ctx, composeSpecialistPrompt(base, sp), task,
|
||||||
draft, err := runAgent(ctx, mdl, fsTools, string(systemBytes), buildTask(diff),
|
|
||||||
envInt("GADFLY_MAX_STEPS", defaultMaxSteps))
|
envInt("GADFLY_MAX_STEPS", defaultMaxSteps))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("review pass: %w", err)
|
fmt.Fprintf(os.Stderr, "gadfly: specialist %q review pass failed: %v\n", sp.Name, err)
|
||||||
|
return fmt.Sprintf("⚠️ This reviewer failed to complete: %v", err), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2 — recheck: adversarially re-verify the draft's findings and drop
|
|
||||||
// the unconfirmed ones. Skipped for a clean draft (nothing to verify) or
|
|
||||||
// when disabled. A recheck failure is non-fatal — we emit the unverified
|
|
||||||
// draft rather than losing the review entirely.
|
|
||||||
final := draft
|
final := draft
|
||||||
if shouldRecheck(draft) {
|
if shouldRecheck(draft) {
|
||||||
rechecked, rerr := runAgent(ctx, mdl, fsTools, recheckSystemPrompt, buildRecheckTask(draft, diff),
|
rechecked, rerr := eng.runPass(ctx, recheckSystemPrompt, buildRecheckTask(draft, diff),
|
||||||
envInt("GADFLY_RECHECK_MAX_STEPS", defaultRecheckMaxSteps))
|
envInt("GADFLY_RECHECK_MAX_STEPS", defaultRecheckMaxSteps))
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
fmt.Fprintln(os.Stderr, "gadfly: recheck pass failed; emitting unverified draft:", rerr)
|
fmt.Fprintf(os.Stderr, "gadfly: specialist %q recheck failed; emitting unverified draft: %v\n", sp.Name, rerr)
|
||||||
} else {
|
} else {
|
||||||
final = rechecked
|
final = rechecked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return final, false
|
||||||
fmt.Println(final)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAgent runs one agent pass (its own fresh toolbox over the sandbox) and
|
// runAgent runs one agent pass (its own fresh toolbox over the sandbox) and
|
||||||
@@ -258,7 +431,7 @@ func buildTask(diff string) string {
|
|||||||
truncNote := ""
|
truncNote := ""
|
||||||
if maxDiff > 0 && len(diff) > maxDiff {
|
if maxDiff > 0 && len(diff) > maxDiff {
|
||||||
diff = diff[:maxDiff]
|
diff = diff[:maxDiff]
|
||||||
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars in this message; call get_diff for the full text.]", maxDiff)
|
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars in this message; read the changed files (or call get_diff, if available) for the full text.]", maxDiff)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -268,7 +441,7 @@ func buildTask(diff string) string {
|
|||||||
if strings.TrimSpace(body) != "" {
|
if strings.TrimSpace(body) != "" {
|
||||||
fmt.Fprintf(&b, "PR description:\n%s\n\n", body)
|
fmt.Fprintf(&b, "PR description:\n%s\n\n", body)
|
||||||
}
|
}
|
||||||
b.WriteString("Review the following unified diff. Before reporting any cross-file or compile-correctness issue, use your tools (read_file, grep, find_files) to verify it against the actual checked-out code — do not rely on the diff alone.\n\n")
|
b.WriteString("Review the following unified diff. Before reporting any cross-file or compile-correctness issue, use your repository read tools to verify it against the actual checked-out code — do not rely on the diff alone.\n\n")
|
||||||
fmt.Fprintf(&b, "```diff\n%s\n```%s", diff, truncNote)
|
fmt.Fprintf(&b, "```diff\n%s\n```%s", diff, truncNote)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/google"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/llamaswap"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultProvider is the provider used when GADFLY_MODEL is a bare model id
|
||||||
|
// (no "provider/" prefix). It keeps existing Ollama Cloud configs — where the
|
||||||
|
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
|
||||||
|
const defaultProvider = "ollama-cloud"
|
||||||
|
|
||||||
|
// resolveModel builds the review model from the environment. Gadfly is powered
|
||||||
|
// by majordomo, so it can target any provider majordomo supports — Ollama
|
||||||
|
// (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible
|
||||||
|
// endpoint — without code changes.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
//
|
||||||
|
// GADFLY_MODEL model id, or a full "provider/model" spec, or a
|
||||||
|
// majordomo failover chain / alias (required).
|
||||||
|
// GADFLY_PROVIDER provider prefix applied when GADFLY_MODEL has no "/"
|
||||||
|
// (default "ollama-cloud"). e.g. "ollama" for a local daemon.
|
||||||
|
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
|
||||||
|
// servers, a remote Ollama, an OpenRouter-style gateway…).
|
||||||
|
// When set, the provider is constructed directly at that URL.
|
||||||
|
// GADFLY_API_KEY bearer/API key for the chosen provider. Optional; when
|
||||||
|
// unset the provider falls back to its standard env var
|
||||||
|
// (OLLAMA_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
|
||||||
|
// GOOGLE_API_KEY|GEMINI_API_KEY). Local Ollama needs none.
|
||||||
|
//
|
||||||
|
// With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so
|
||||||
|
// LLM_* env DSNs and registered aliases/tiers work too.
|
||||||
|
func resolveModel() (llm.Model, error) {
|
||||||
|
// Register any env-defined endpoints/aliases first so they're resolvable as
|
||||||
|
// "<name>/<model>" specs below. Best-effort: a malformed entry is logged and
|
||||||
|
// skipped rather than failing the whole review.
|
||||||
|
for _, err := range registerEnvProviders() {
|
||||||
|
fmt.Fprintln(os.Stderr, "gadfly: ignoring bad endpoint/alias:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
|
||||||
|
if model == "" {
|
||||||
|
return nil, fmt.Errorf("GADFLY_MODEL is required")
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
|
||||||
|
if provider == "" {
|
||||||
|
provider = defaultProvider
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimSpace(os.Getenv("GADFLY_BASE_URL"))
|
||||||
|
apiKey := os.Getenv("GADFLY_API_KEY")
|
||||||
|
|
||||||
|
// No endpoint override: let majordomo's registry resolve the spec. This
|
||||||
|
// path supports built-in providers (reading their standard key envs),
|
||||||
|
// LLM_* env DSNs, and aliases/failover chains.
|
||||||
|
if baseURL == "" {
|
||||||
|
return majordomo.Parse(buildSpec(provider, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint override: construct the provider directly at the given URL.
|
||||||
|
switch provider {
|
||||||
|
case "openai", "openai-compatible":
|
||||||
|
opts := []openai.Option{openai.WithBaseURL(baseURL)}
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||||
|
}
|
||||||
|
return openai.New(opts...).Model(model)
|
||||||
|
case "ollama", "ollama-cloud":
|
||||||
|
opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, ollama.WithToken(apiKey))
|
||||||
|
}
|
||||||
|
return ollama.New(opts...).Model(model)
|
||||||
|
case "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
|
||||||
|
// llama-swap (model-swapping proxy). Accept every spelling: hyphenated
|
||||||
|
// ("llama-swap"/"llama-swaps") mirrors majordomo's DSN schemes (http vs
|
||||||
|
// https), and the un-hyphenated forms are accepted too. With an explicit
|
||||||
|
// GADFLY_BASE_URL the scheme is whatever the URL says, so all behave the same.
|
||||||
|
opts := []llamaswap.Option{llamaswap.WithBaseURL(baseURL)}
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, llamaswap.WithToken(apiKey))
|
||||||
|
}
|
||||||
|
return llamaswap.New(opts...).Model(model)
|
||||||
|
case "foreman":
|
||||||
|
// foreman (gitea.stevedudenhoeffer.com/steve/foreman) is a native-Ollama
|
||||||
|
// queue daemon; the preset also smooths its non-streaming/long-poll
|
||||||
|
// quirks. Base URL is used verbatim, so a plaintext http:// foreman works.
|
||||||
|
return ollama.Foreman(baseURL, apiKey).Model(model)
|
||||||
|
case "anthropic":
|
||||||
|
opts := []anthropic.Option{anthropic.WithBaseURL(baseURL)}
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, anthropic.WithAPIKey(apiKey))
|
||||||
|
}
|
||||||
|
return anthropic.New(opts...).Model(model)
|
||||||
|
case "google", "gemini":
|
||||||
|
opts := []google.Option{google.WithBaseURL(baseURL)}
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, google.WithAPIKey(apiKey))
|
||||||
|
}
|
||||||
|
return google.New(opts...).Model(model)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use openai/openai-compatible/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWorkerModel builds the optional worker model for delegate_investigation
|
||||||
|
// from GADFLY_WORKER_MODEL (a cheap tier is ideal). Returns (nil, nil) when
|
||||||
|
// unset — delegation is simply off. Honors GADFLY_PROVIDER for a bare id.
|
||||||
|
func resolveWorkerModel() (llm.Model, error) {
|
||||||
|
spec := strings.TrimSpace(os.Getenv("GADFLY_WORKER_MODEL"))
|
||||||
|
if spec == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
|
||||||
|
if provider == "" {
|
||||||
|
provider = defaultProvider
|
||||||
|
}
|
||||||
|
return majordomo.Parse(buildSpec(provider, spec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSpec turns (provider, model) into a majordomo spec. A model id that
|
||||||
|
// already carries a "provider/" prefix (or is a multi-element failover chain)
|
||||||
|
// is passed through verbatim; a bare id is prefixed with the provider.
|
||||||
|
func buildSpec(provider, model string) string {
|
||||||
|
if strings.Contains(model, "/") || strings.Contains(model, ",") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return provider + "/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelProvider returns the provider "lane" for THIS run's model, mirroring
|
||||||
|
// entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL,
|
||||||
|
// else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one
|
||||||
|
// model per invocation, so this is that model's provider — used to resolve
|
||||||
|
// per-provider policy (e.g. lens concurrency) against the SAME provider keys
|
||||||
|
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY.
|
||||||
|
func modelProvider() string {
|
||||||
|
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
|
||||||
|
if pfx, _, ok := strings.Cut(model, "/"); ok {
|
||||||
|
return strings.TrimSpace(pfx)
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return defaultProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerEnvProviders reads named endpoints and aliases from the environment
|
||||||
|
// and registers them with majordomo's default registry, so they can be used as
|
||||||
|
// "<name>/<model>" specs (or bare aliases) in GADFLY_MODEL.
|
||||||
|
//
|
||||||
|
// Two env families (NAME is lowercased to form the registry name, like
|
||||||
|
// majordomo's own LLM_* convention — GADFLY_ENDPOINT_BIGBOX → "bigbox"):
|
||||||
|
//
|
||||||
|
// GADFLY_ENDPOINT_<NAME> = "<provider>|<base-url>[|<key>]"
|
||||||
|
// Registers a provider at an explicit endpoint. Unlike majordomo's LLM_*
|
||||||
|
// DSNs (which are HTTPS-only), the base URL is used verbatim, so a
|
||||||
|
// plaintext local Ollama (or foreman queue) works:
|
||||||
|
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
|
||||||
|
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
|
||||||
|
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman"
|
||||||
|
// targets a foreman daemon (native Ollama on the wire):
|
||||||
|
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
|
||||||
|
//
|
||||||
|
// GADFLY_ALIAS_<NAME> = "<majordomo spec>"
|
||||||
|
// Registers a plain alias that expands inline (a model, or a failover
|
||||||
|
// chain): GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud".
|
||||||
|
//
|
||||||
|
// Returns one error per malformed entry; valid entries still register.
|
||||||
|
func registerEnvProviders() []error {
|
||||||
|
var errs []error
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
key, val, _ := strings.Cut(kv, "=")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(key, "GADFLY_ENDPOINT_") && len(key) > len("GADFLY_ENDPOINT_"):
|
||||||
|
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ENDPOINT_"))
|
||||||
|
p, err := endpointProvider(name, val)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
majordomo.RegisterProvider(p)
|
||||||
|
case strings.HasPrefix(key, "GADFLY_ALIAS_") && len(key) > len("GADFLY_ALIAS_"):
|
||||||
|
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ALIAS_"))
|
||||||
|
spec := strings.TrimSpace(val)
|
||||||
|
if spec == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: empty alias spec", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
majordomo.RegisterAlias(name, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointProvider builds a named provider from a "provider|base-url[|key]"
|
||||||
|
// value. The base URL is honored verbatim (http or https).
|
||||||
|
func endpointProvider(name, raw string) (llm.Provider, error) {
|
||||||
|
parts := strings.SplitN(raw, "|", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("want \"<provider>|<base-url>[|<key>]\", got %q", raw)
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(parts[0])
|
||||||
|
baseURL := strings.TrimSpace(parts[1])
|
||||||
|
key := ""
|
||||||
|
if len(parts) == 3 {
|
||||||
|
key = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, fmt.Errorf("missing base URL in %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "ollama", "ollama-cloud":
|
||||||
|
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
|
||||||
|
if key != "" {
|
||||||
|
opts = append(opts, ollama.WithToken(key))
|
||||||
|
}
|
||||||
|
return ollama.New(opts...), nil
|
||||||
|
case "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
|
||||||
|
opts := []llamaswap.Option{llamaswap.WithName(name), llamaswap.WithBaseURL(baseURL)}
|
||||||
|
if key != "" {
|
||||||
|
opts = append(opts, llamaswap.WithToken(key))
|
||||||
|
}
|
||||||
|
return llamaswap.New(opts...), nil
|
||||||
|
case "foreman":
|
||||||
|
// foreman is native-Ollama on the wire; the preset additionally handles
|
||||||
|
// its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman://
|
||||||
|
// DSN, the base URL here is verbatim, so a plaintext http:// foreman works.
|
||||||
|
return ollama.Foreman(baseURL, key, ollama.WithName(name)), nil
|
||||||
|
case "openai", "openai-compatible":
|
||||||
|
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
|
||||||
|
if key != "" {
|
||||||
|
opts = append(opts, openai.WithAPIKey(key))
|
||||||
|
}
|
||||||
|
return openai.New(opts...), nil
|
||||||
|
case "anthropic":
|
||||||
|
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
|
||||||
|
if key != "" {
|
||||||
|
opts = append(opts, anthropic.WithAPIKey(key))
|
||||||
|
}
|
||||||
|
return anthropic.New(opts...), nil
|
||||||
|
case "google", "gemini":
|
||||||
|
opts := []google.Option{google.WithName(name), google.WithBaseURL(baseURL)}
|
||||||
|
if key != "" {
|
||||||
|
opts = append(opts, google.WithAPIKey(key))
|
||||||
|
}
|
||||||
|
return google.New(opts...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown provider %q (use ollama/llama-swap(s)/foreman/openai/openai-compatible/anthropic/google)", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEndpointProvider(t *testing.T) {
|
||||||
|
t.Run("ollama http endpoint registers under its name", func(t *testing.T) {
|
||||||
|
p, err := endpointProvider("bigbox", "ollama|http://192.168.1.50:11434")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p.Name() != "bigbox" {
|
||||||
|
t.Errorf("Name() = %q, want %q", p.Name(), "bigbox")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("openai compatible with key", func(t *testing.T) {
|
||||||
|
if _, err := endpointProvider("gpu", "openai|http://gpu.lan:8000/v1|sk-x"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("foreman queue registers under its name", func(t *testing.T) {
|
||||||
|
p, err := endpointProvider("m1", "foreman|http://foreman-m1:8080|tok")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// WithName(name) must win over the Foreman preset's default "foreman".
|
||||||
|
if p.Name() != "m1" {
|
||||||
|
t.Errorf("Name() = %q, want %q", p.Name(), "m1")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("foreman without token", func(t *testing.T) {
|
||||||
|
if _, err := endpointProvider("m5", "foreman|http://foreman-m5:8080"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("llamaswap registers under its name", func(t *testing.T) {
|
||||||
|
p, err := endpointProvider("ls", "llamaswap|http://swap.lan:8080|tok")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p.Name() != "ls" {
|
||||||
|
t.Errorf("Name() = %q, want %q", p.Name(), "ls")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("llamaswap without token", func(t *testing.T) {
|
||||||
|
if _, err := endpointProvider("ls2", "llamaswap|http://swap.lan:8080"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// All llama-swap spellings (hyphenated/TLS variants mirror majordomo's DSN
|
||||||
|
// schemes) must resolve to the llamaswap provider.
|
||||||
|
for _, name := range []string{"llama-swap", "llama-swaps", "llamaswaps"} {
|
||||||
|
t.Run(name+" alias", func(t *testing.T) {
|
||||||
|
p, err := endpointProvider("ls", name+"|https://swap.lan:8080|tok")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p.Name() != "ls" {
|
||||||
|
t.Errorf("Name() = %q, want %q", p.Name(), "ls")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "ollama", "noprovider-no-pipe", "mystery|http://x"} {
|
||||||
|
t.Run("rejects "+bad, func(t *testing.T) {
|
||||||
|
if _, err := endpointProvider("n", bad); err == nil {
|
||||||
|
t.Errorf("endpointProvider(%q) = nil error, want error", bad)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpec(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"bare id gets provider prefix", "ollama-cloud", "qwen3-coder:480b-cloud", "ollama-cloud/qwen3-coder:480b-cloud"},
|
||||||
|
{"bare id local ollama", "ollama", "llama3.1", "ollama/llama3.1"},
|
||||||
|
{"already has provider passes through", "ollama-cloud", "openai/gpt-4o", "openai/gpt-4o"},
|
||||||
|
{"slashed model name passes through verbatim", "openai", "openai/meta-llama/Llama-3.1", "openai/meta-llama/Llama-3.1"},
|
||||||
|
{"failover chain passes through", "ollama-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := buildSpec(tt.provider, tt.model); got != tt.want {
|
||||||
|
t.Errorf("buildSpec(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,14 @@ const defaultRecheckMaxSteps = 16
|
|||||||
// against the real code before letting it survive — the antidote to a
|
// against the real code before letting it survive — the antidote to a
|
||||||
// single-pass reviewer that reads a couple of files, mis-connects them, and
|
// single-pass reviewer that reads a couple of files, mis-connects them, and
|
||||||
// posts a confident but wrong "blocking" verdict.
|
// posts a confident but wrong "blocking" verdict.
|
||||||
const recheckSystemPrompt = `You are a VERIFICATION GATE for an automated adversarial code review of the
|
const recheckSystemPrompt = `You are a VERIFICATION GATE for an automated adversarial code review. You are
|
||||||
"mort" project (a large Go Discord bot). You are given a DRAFT review produced
|
given a DRAFT review produced by another model. Your job is NOT to write a new
|
||||||
by another model. Your job is NOT to write a new review — it is to confirm or
|
review — it is to confirm or reject each finding in the draft against the ACTUAL
|
||||||
reject each finding in the draft against the ACTUAL code, then output the
|
code, then output the corrected review.
|
||||||
corrected review.
|
|
||||||
|
|
||||||
You have the same read-only repository tools as the original reviewer:
|
You have read-only access to the checked-out repository — use your tools to read
|
||||||
- read_file(path[, start_line, limit]), list_dir([path]), grep(pattern[, path,
|
files and search the code to independently verify each finding against the real
|
||||||
max_results]), find_files(name[, max_results]), get_diff().
|
source.
|
||||||
|
|
||||||
For EVERY finding in the draft:
|
For EVERY finding in the draft:
|
||||||
1. Independently reproduce the reasoning by reading the actual files with your
|
1. Independently reproduce the reasoning by reading the actual files with your
|
||||||
@@ -84,7 +83,7 @@ func buildRecheckTask(draft, diff string) string {
|
|||||||
truncNote := ""
|
truncNote := ""
|
||||||
if maxDiff > 0 && len(diff) > maxDiff {
|
if maxDiff > 0 && len(diff) > maxDiff {
|
||||||
diff = diff[:maxDiff]
|
diff = diff[:maxDiff]
|
||||||
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars here; call get_diff for the full text.]", maxDiff)
|
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars here; read the changed files (or call get_diff, if available) for the full text.]", maxDiff)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Specialist is one review lens: a focused instruction appended to the base
|
||||||
|
// system prompt, run as its own review (+recheck) pass and rendered as its own
|
||||||
|
// section in the consolidated comment.
|
||||||
|
type Specialist struct {
|
||||||
|
Name string // lowercase id, used in GADFLY_SPECIALISTS and section anchors
|
||||||
|
Title string // human label for the section header
|
||||||
|
Focus string // lens-specific instruction appended to the base prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinSpecialists are the lenses Gadfly ships with. The default suite (below)
|
||||||
|
// is the "biggies"; the rest are opt-in by name via GADFLY_SPECIALISTS, a
|
||||||
|
// .gadfly.yml `specialists:` list, or `all`.
|
||||||
|
var builtinSpecialists = map[string]Specialist{
|
||||||
|
"security": {"security", "🔒 Security", "" +
|
||||||
|
"Authn/authz gaps, injection (SQL/command/path/template), SSRF, unsafe deserialization, " +
|
||||||
|
"secret/credential leakage, missing input validation, unsafe handling of untrusted data, " +
|
||||||
|
"and insecure defaults. Trace tainted input to where it's used."},
|
||||||
|
"correctness": {"correctness", "🎯 Correctness", "" +
|
||||||
|
"Logic bugs and incorrect behavior introduced by the change. Pay special attention to " +
|
||||||
|
"SEMANTIC / domain correctness: do NOT trust a constant, conversion factor, formula, unit, " +
|
||||||
|
"or threshold because it looks reasonable — independently RE-DERIVE the expected value from " +
|
||||||
|
"first principles (units, dimensions, boundary values) and compare. A plausible-looking magic " +
|
||||||
|
"number is exactly where real bugs hide."},
|
||||||
|
"maintainability": {"maintainability", "🧹 Code cleanliness & maintainability", "" +
|
||||||
|
"Readability and structure: dead/duplicated code, confusing names, overly long or deeply " +
|
||||||
|
"nested functions, leaky abstractions, copy-paste that should be shared, and changes that " +
|
||||||
|
"don't follow the patterns the surrounding code already uses. Prefer concrete, low-churn fixes."},
|
||||||
|
"performance": {"performance", "⚡ Performance", "" +
|
||||||
|
"Efficiency regressions: N+1 queries, unnecessary allocations or copies, work inside hot loops, " +
|
||||||
|
"unbounded growth, missing pagination/limits, blocking calls on hot paths, and avoidable " +
|
||||||
|
"quadratic behavior. Only flag impact you can justify, not micro-optimizations."},
|
||||||
|
"error-handling": {"error-handling", "🧯 Error handling & edge cases", "" +
|
||||||
|
"Ignored or swallowed errors, missing cleanup/rollback/`defer`, panics on bad input, and " +
|
||||||
|
"unhandled edge cases: nil/null, empty collections, zero/negative, integer overflow, and " +
|
||||||
|
"boundary (off-by-one) conditions. Check the unhappy paths the diff introduces."},
|
||||||
|
|
||||||
|
// --- opt-in (not in the default suite) ---
|
||||||
|
"tests": {"tests", "🧪 Tests", "" +
|
||||||
|
"Whether the change is adequately tested: new logic without tests, changed behavior whose " +
|
||||||
|
"tests weren't updated, missing edge-case/error-path coverage, and brittle or assertion-free tests."},
|
||||||
|
"docs": {"docs", "📝 Docs", "" +
|
||||||
|
"Documentation drift: public APIs/flags/config changed without updating doc comments, README, " +
|
||||||
|
"or examples; stale comments now contradicting the code; missing docs for new exported surface."},
|
||||||
|
"conventions": {"conventions", "📐 Conventions", "" +
|
||||||
|
"Adherence to THIS repo's own conventions. Discover them — read any README / CONTRIBUTING / " +
|
||||||
|
"CLAUDE.md / AGENTS.md / lint config the repo ships, and hold the change to the documented and " +
|
||||||
|
"surrounding-code patterns."},
|
||||||
|
"improvements": {"improvements", "💡 Improvements (optional)", "" +
|
||||||
|
"OPTIONAL, non-blocking suggestions only. Surface at most 1–2 genuinely high-value blind spots " +
|
||||||
|
"or simplifications the author likely wants to know about. Be quiet: if nothing rises to that " +
|
||||||
|
"bar, reply exactly 'No suggestions.' Never nitpick, never pad. These never affect the verdict."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSuite is used when nothing selects specialists explicitly.
|
||||||
|
var defaultSuite = []string{"security", "correctness", "maintainability", "performance", "error-handling"}
|
||||||
|
|
||||||
|
// fileConfig is the optional .gadfly.yml schema.
|
||||||
|
type fileConfig struct {
|
||||||
|
// Specialists selects which run (names). Empty => the default suite.
|
||||||
|
Specialists []string `yaml:"specialists"`
|
||||||
|
// Define adds or overrides specialist definitions.
|
||||||
|
Define []struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Focus string `yaml:"focus"`
|
||||||
|
} `yaml:"define"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSpecialists builds the active specialist list from (in increasing
|
||||||
|
// precedence) the built-ins, a repo .gadfly.yml, and GADFLY_SPECIALIST_<NAME>
|
||||||
|
// env definitions. The active SELECTION is GADFLY_SPECIALISTS (csv, "all", or
|
||||||
|
// "auto"), else the file's `specialists:`, else the default suite. Unknown
|
||||||
|
// names are reported as errors and skipped; valid ones still run.
|
||||||
|
//
|
||||||
|
// It also returns the full registry (the catalog of every defined specialist)
|
||||||
|
// and an auto flag: when the selection is "auto", specs is nil and the caller
|
||||||
|
// runs the dynamic selector (see auto.go) against the registry instead.
|
||||||
|
func resolveSpecialists(repoDir string) (specs []Specialist, registry map[string]Specialist, auto bool, errs []error) {
|
||||||
|
|
||||||
|
// 1. registry seeded with built-ins.
|
||||||
|
registry = make(map[string]Specialist, len(builtinSpecialists))
|
||||||
|
for k, v := range builtinSpecialists {
|
||||||
|
registry[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. config file: definitions + (maybe) the selection.
|
||||||
|
var fileSelection []string
|
||||||
|
if cfg, ok, err := loadFileConfig(repoDir); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
} else if ok {
|
||||||
|
for _, d := range cfg.Define {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(d.Name))
|
||||||
|
if name == "" || strings.TrimSpace(d.Focus) == "" {
|
||||||
|
errs = append(errs, fmt.Errorf(".gadfly.yml: specialist define needs non-empty name and focus"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
registry[name] = Specialist{Name: name, Title: titleOr(d.Title, name), Focus: d.Focus}
|
||||||
|
}
|
||||||
|
fileSelection = cfg.Specialists
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. env definitions: GADFLY_SPECIALIST_<NAME>="focus".
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
key, val, _ := strings.Cut(kv, "=")
|
||||||
|
if !strings.HasPrefix(key, "GADFLY_SPECIALIST_") || len(key) == len("GADFLY_SPECIALIST_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_SPECIALIST_"))
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: empty specialist focus", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, ok := registry[name]
|
||||||
|
title := titleOr("", name)
|
||||||
|
if ok {
|
||||||
|
title = existing.Title
|
||||||
|
}
|
||||||
|
registry[name] = Specialist{Name: name, Title: title, Focus: val}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. selection: env > file > default suite.
|
||||||
|
var names []string
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(os.Getenv("GADFLY_SPECIALISTS")) != "":
|
||||||
|
names = splitCSV(os.Getenv("GADFLY_SPECIALISTS"))
|
||||||
|
case len(fileSelection) > 0:
|
||||||
|
names = fileSelection
|
||||||
|
default:
|
||||||
|
names = defaultSuite
|
||||||
|
}
|
||||||
|
if len(names) == 1 && strings.EqualFold(names[0], "auto") {
|
||||||
|
// Dynamic selection: caller runs the selector against the registry.
|
||||||
|
return nil, registry, true, errs
|
||||||
|
}
|
||||||
|
if len(names) == 1 && strings.EqualFold(names[0], "all") {
|
||||||
|
names = sortedKeys(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. resolve names -> specialists.
|
||||||
|
var out []Specialist
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, raw := range names {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
sp, ok := registry[name]
|
||||||
|
if !ok {
|
||||||
|
errs = append(errs, fmt.Errorf("unknown specialist %q (built-ins: %s; or define it via .gadfly.yml / GADFLY_SPECIALIST_%s)",
|
||||||
|
name, strings.Join(sortedKeys(builtinSpecialists), ", "), strings.ToUpper(name)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
return out, registry, false, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// suiteFromRegistry resolves names against the registry, skipping any missing.
|
||||||
|
// Used as the auto-mode fallback (the default suite always exists in builtins).
|
||||||
|
func suiteFromRegistry(registry map[string]Specialist, names []string) []Specialist {
|
||||||
|
var out []Specialist
|
||||||
|
for _, n := range names {
|
||||||
|
if sp, ok := registry[n]; ok {
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// specialistNamesOf returns the names of a specialist slice (for logging).
|
||||||
|
func specialistNamesOf(specs []Specialist) []string {
|
||||||
|
names := make([]string, len(specs))
|
||||||
|
for i, s := range specs {
|
||||||
|
names[i] = s.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeSpecialistPrompt appends a specialist's lens to the base system prompt.
|
||||||
|
func composeSpecialistPrompt(base string, sp Specialist) string {
|
||||||
|
return strings.TrimRight(base, "\n") +
|
||||||
|
"\n\n## Your assigned lens — " + sp.Title + "\n" +
|
||||||
|
"Review the change specifically and ONLY through this lens. Scrutinize it for:\n" +
|
||||||
|
sp.Focus +
|
||||||
|
"\n\nStay in this lane: other lenses (correctness, security, performance, etc.) are reviewed " +
|
||||||
|
"separately, so don't duplicate their findings here. If nothing in your lens is materially " +
|
||||||
|
"wrong, reply with the \"No material issues found\" verdict for this lens — do not reach for " +
|
||||||
|
"another lens's issue just to have something to say."
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFileConfig(repoDir string) (fileConfig, bool, error) {
|
||||||
|
candidates := []string{}
|
||||||
|
if p := strings.TrimSpace(os.Getenv("GADFLY_CONFIG")); p != "" {
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(repoDir, p)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, p)
|
||||||
|
} else {
|
||||||
|
candidates = append(candidates, filepath.Join(repoDir, ".gadfly.yml"), filepath.Join(repoDir, ".gadfly.yaml"))
|
||||||
|
}
|
||||||
|
for _, p := range candidates {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue // not found / unreadable: try next
|
||||||
|
}
|
||||||
|
var cfg fileConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return fileConfig{}, false, fmt.Errorf("parse %s: %w", filepath.Base(p), err)
|
||||||
|
}
|
||||||
|
return cfg, true, nil
|
||||||
|
}
|
||||||
|
return fileConfig{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOr(title, name string) string {
|
||||||
|
if strings.TrimSpace(title) != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]Specialist) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func names(specs []Specialist) []string {
|
||||||
|
out := make([]string, len(specs))
|
||||||
|
for i, s := range specs {
|
||||||
|
out[i] = s.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func eq(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_DefaultSuite(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "")
|
||||||
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if !eq(names(specs), defaultSuite) {
|
||||||
|
t.Errorf("default = %v, want %v", names(specs), defaultSuite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_EnvSelection(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "security, tests")
|
||||||
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if !eq(names(specs), []string{"security", "tests"}) {
|
||||||
|
t.Errorf("got %v", names(specs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_AutoFlag(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "auto")
|
||||||
|
specs, registry, auto, errs := resolveSpecialists(t.TempDir())
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if !auto {
|
||||||
|
t.Error("expected auto=true for GADFLY_SPECIALISTS=auto")
|
||||||
|
}
|
||||||
|
if specs != nil {
|
||||||
|
t.Errorf("auto mode should return nil specs, got %v", names(specs))
|
||||||
|
}
|
||||||
|
if len(registry) == 0 {
|
||||||
|
t.Error("auto mode should still return the registry catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_UnknownNameErrors(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "security,bogus")
|
||||||
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatal("expected an error for unknown specialist")
|
||||||
|
}
|
||||||
|
if !eq(names(specs), []string{"security"}) {
|
||||||
|
t.Errorf("valid ones should still resolve, got %v", names(specs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_EnvCustomDefinition(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_SPECIALIST_MIGRATIONS", "Review DB migrations for destructive ops.")
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "migrations")
|
||||||
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if len(specs) != 1 || specs[0].Name != "migrations" || specs[0].Focus == "" {
|
||||||
|
t.Fatalf("custom specialist not registered: %+v", specs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecialists_FileConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := `specialists: [security, migrations]
|
||||||
|
define:
|
||||||
|
- name: migrations
|
||||||
|
title: "DB migrations"
|
||||||
|
focus: "Review schema migrations for destructive or unindexed changes."
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gadfly.yml"), []byte(cfg), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("GADFLY_SPECIALISTS", "") // let the file drive selection
|
||||||
|
specs, _, _, errs := resolveSpecialists(dir)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if !eq(names(specs), []string{"security", "migrations"}) {
|
||||||
|
t.Errorf("got %v", names(specs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictAndWorst(t *testing.T) {
|
||||||
|
cases := map[string]verdict{
|
||||||
|
"VERDICT: No material issues found.": verdictClean,
|
||||||
|
"Minor issues\n- nit": verdictMinor,
|
||||||
|
"**Blocking issues found**": verdictBlocking,
|
||||||
|
"**Blocking issues**\n- bug": verdictBlocking, // no "found" suffix
|
||||||
|
"VERDICT: Blocking issue\n- one": verdictBlocking, // singular
|
||||||
|
"No material issues found. There are no blocking issues.": verdictClean, // earliest phrase wins
|
||||||
|
"something unparseable": verdictUnknown,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := parseVerdict(in); got != want {
|
||||||
|
t.Errorf("parseVerdict(%q) = %v, want %v", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results := []specialistResult{
|
||||||
|
{spec: Specialist{Name: "security"}, verdict: verdictMinor},
|
||||||
|
{spec: Specialist{Name: "correctness"}, verdict: verdictBlocking},
|
||||||
|
{spec: Specialist{Name: "improvements"}, verdict: verdictBlocking}, // must not count
|
||||||
|
}
|
||||||
|
if w := worstVerdict(results[:2]); w != verdictBlocking {
|
||||||
|
t.Errorf("worst = %v, want blocking", w)
|
||||||
|
}
|
||||||
|
if w := worstVerdict([]specialistResult{results[0], results[2]}); w != verdictMinor {
|
||||||
|
t.Errorf("improvements should not escalate; worst = %v, want minor", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lens states for the live status board. A lens starts queued, becomes running
|
||||||
|
// when its pass begins, and ends finished (with a verdict, or errored).
|
||||||
|
const (
|
||||||
|
lensQueued = "queued"
|
||||||
|
lensRunning = "running"
|
||||||
|
lensFinished = "finished"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lensStatus is one specialist lens's progress, as rendered by the entrypoint
|
||||||
|
// status board (scripts/status-board.sh).
|
||||||
|
type lensStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"` // queued | running | finished
|
||||||
|
Verdict string `json:"verdict,omitempty"` // set when finished (the lens's label)
|
||||||
|
Errored bool `json:"errored,omitempty"` // the lens failed to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelStatus is the on-disk shape one model process publishes for the live
|
||||||
|
// status board: a snapshot of this model's lenses as they progress. The board
|
||||||
|
// reads every model's file and renders a single consolidated PR comment.
|
||||||
|
type modelStatus struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Started int64 `json:"started"` // unix seconds
|
||||||
|
Updated int64 `json:"updated"` // unix seconds, bumped on every change
|
||||||
|
Done bool `json:"done"` // all lenses finished
|
||||||
|
Lenses []lensStatus `json:"lenses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusWriter maintains a model's status file as its lenses progress. It is
|
||||||
|
// purely opt-in: when GADFLY_STATUS_FILE is unset the writer's path is empty and
|
||||||
|
// every method is a no-op, so a plain run (and the unit tests) never touch the
|
||||||
|
// filesystem and behave exactly as before. Writes are atomic (temp file +
|
||||||
|
// rename within the same dir) so the board never reads a half-written file even
|
||||||
|
// though lenses can finish concurrently.
|
||||||
|
type statusWriter struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
st modelStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// newStatusWriter seeds a writer with every lens queued and flushes the initial
|
||||||
|
// snapshot. model/provider are echoed into the file so the board can render
|
||||||
|
// them without re-deriving from the filename (which is sanitized). The status
|
||||||
|
// file path comes from GADFLY_STATUS_FILE (set by run.sh per model); when empty
|
||||||
|
// the writer is inert.
|
||||||
|
func newStatusWriter(model, provider string, specialists []Specialist) *statusWriter {
|
||||||
|
w := &statusWriter{path: strings.TrimSpace(os.Getenv("GADFLY_STATUS_FILE"))}
|
||||||
|
w.st = modelStatus{
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
Started: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
for _, sp := range specialists {
|
||||||
|
w.st.Lenses = append(w.st.Lenses, lensStatus{Name: sp.Name, State: lensQueued})
|
||||||
|
}
|
||||||
|
w.flush()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// set transitions a lens to a new state (and verdict/errored when finished),
|
||||||
|
// recomputes the overall done flag, and atomically rewrites the file. Unknown
|
||||||
|
// lens names are ignored. Safe for concurrent callers (one goroutine per lens).
|
||||||
|
func (w *statusWriter) set(name, state, verdict string, errored bool) {
|
||||||
|
if w == nil || w.path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
for i := range w.st.Lenses {
|
||||||
|
if w.st.Lenses[i].Name == name {
|
||||||
|
w.st.Lenses[i].State = state
|
||||||
|
w.st.Lenses[i].Verdict = verdict
|
||||||
|
w.st.Lenses[i].Errored = errored
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := true
|
||||||
|
for _, l := range w.st.Lenses {
|
||||||
|
if l.State != lensFinished {
|
||||||
|
done = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.st.Done = done
|
||||||
|
w.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush writes the current snapshot atomically. Best-effort: any error is
|
||||||
|
// swallowed (the status board is advisory and must never affect the review).
|
||||||
|
func (w *statusWriter) flush() {
|
||||||
|
if w.path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.st.Updated = time.Now().Unix()
|
||||||
|
data, err := json.MarshalIndent(&w.st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(w.path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".status-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Rename is atomic within the same filesystem, so the board reader sees
|
||||||
|
// either the old complete file or the new complete file — never a partial.
|
||||||
|
if err := os.Rename(tmpName, w.path); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readStatus loads a modelStatus written by the statusWriter.
|
||||||
|
func readStatus(t *testing.T, path string) modelStatus {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read status file: %v", err)
|
||||||
|
}
|
||||||
|
var st modelStatus
|
||||||
|
if err := json.Unmarshal(data, &st); err != nil {
|
||||||
|
t.Fatalf("unmarshal status: %v", err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusWriterLifecycle(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "glm.json")
|
||||||
|
t.Setenv("GADFLY_STATUS_FILE", path)
|
||||||
|
|
||||||
|
specs := []Specialist{
|
||||||
|
{Name: "security", Title: "Security"},
|
||||||
|
{Name: "correctness", Title: "Correctness"},
|
||||||
|
}
|
||||||
|
w := newStatusWriter("glm-5.2:cloud", "ollama-cloud", specs)
|
||||||
|
|
||||||
|
// Initial snapshot: both lenses queued, model not done, metadata echoed.
|
||||||
|
st := readStatus(t, path)
|
||||||
|
if st.Model != "glm-5.2:cloud" || st.Provider != "ollama-cloud" {
|
||||||
|
t.Fatalf("model/provider not echoed: %+v", st)
|
||||||
|
}
|
||||||
|
if len(st.Lenses) != 2 {
|
||||||
|
t.Fatalf("want 2 lenses, got %d", len(st.Lenses))
|
||||||
|
}
|
||||||
|
for _, l := range st.Lenses {
|
||||||
|
if l.State != lensQueued {
|
||||||
|
t.Fatalf("lens %q want queued, got %q", l.Name, l.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st.Done {
|
||||||
|
t.Fatal("model marked done while lenses still queued")
|
||||||
|
}
|
||||||
|
if st.Started == 0 {
|
||||||
|
t.Fatal("started timestamp not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition one lens through running -> finished; model not yet done.
|
||||||
|
w.set("security", lensRunning, "", false)
|
||||||
|
if got := readStatus(t, path); got.Lenses[0].State != lensRunning {
|
||||||
|
t.Fatalf("security want running, got %q", got.Lenses[0].State)
|
||||||
|
}
|
||||||
|
w.set("security", lensFinished, "No material issues found", false)
|
||||||
|
st = readStatus(t, path)
|
||||||
|
if st.Lenses[0].State != lensFinished || st.Lenses[0].Verdict != "No material issues found" {
|
||||||
|
t.Fatalf("security finish not recorded: %+v", st.Lenses[0])
|
||||||
|
}
|
||||||
|
if st.Done {
|
||||||
|
t.Fatal("model marked done with one lens still queued")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish the second lens (errored) -> model done.
|
||||||
|
w.set("correctness", lensFinished, "Reviewed", true)
|
||||||
|
st = readStatus(t, path)
|
||||||
|
if !st.Done {
|
||||||
|
t.Fatal("model should be done after all lenses finished")
|
||||||
|
}
|
||||||
|
if !st.Lenses[1].Errored {
|
||||||
|
t.Fatal("errored flag not recorded for correctness")
|
||||||
|
}
|
||||||
|
if st.Updated < st.Started {
|
||||||
|
t.Fatalf("updated (%d) should be >= started (%d)", st.Updated, st.Started)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With GADFLY_STATUS_FILE unset the writer is inert: no file, no panic.
|
||||||
|
func TestStatusWriterDisabled(t *testing.T) {
|
||||||
|
t.Setenv("GADFLY_STATUS_FILE", "")
|
||||||
|
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
|
||||||
|
w.set("security", lensFinished, "Minor issues", false)
|
||||||
|
// Nothing to assert beyond "did not panic / did not write" — a nil-safe set
|
||||||
|
// on the disabled writer is the contract.
|
||||||
|
if w.path != "" {
|
||||||
|
t.Fatalf("expected empty path when disabled, got %q", w.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set must ignore unknown lens names rather than panic or append.
|
||||||
|
func TestStatusWriterUnknownLens(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "s.json")
|
||||||
|
t.Setenv("GADFLY_STATUS_FILE", path)
|
||||||
|
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
|
||||||
|
w.set("does-not-exist", lensRunning, "", false)
|
||||||
|
if st := readStatus(t, path); len(st.Lenses) != 1 || st.Lenses[0].State != lensQueued {
|
||||||
|
t.Fatalf("unknown lens mutated state: %+v", st.Lenses)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-6
@@ -37,8 +37,9 @@ var skipDirs = map[string]bool{
|
|||||||
// escapes (symlink or `..` traversal), so a hostile diff can never make the
|
// escapes (symlink or `..` traversal), so a hostile diff can never make the
|
||||||
// reviewer read outside the checkout.
|
// reviewer read outside the checkout.
|
||||||
type repoFS struct {
|
type repoFS struct {
|
||||||
root string // absolute, symlink-resolved repo root
|
root string // absolute, symlink-resolved repo root
|
||||||
diff string // the full PR unified diff (served by get_diff)
|
diff string // the full PR unified diff (served by get_diff)
|
||||||
|
worker llm.Model // optional cheap model for delegate_investigation; nil = no delegation
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRepoFS resolves root to an absolute, symlink-free path.
|
// newRepoFS resolves root to an absolute, symlink-free path.
|
||||||
@@ -96,16 +97,25 @@ func (r *repoFS) contains(abs string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolbox builds the read-only review toolbox over this sandbox.
|
// fsTools is the set of read-only repository tools.
|
||||||
func (r *repoFS) toolbox() (*llm.Toolbox, error) {
|
func (r *repoFS) fsTools() []llm.Tool {
|
||||||
box := llm.NewToolbox("gadfly")
|
return []llm.Tool{
|
||||||
tools := []llm.Tool{
|
|
||||||
r.readFileTool(),
|
r.readFileTool(),
|
||||||
r.listDirTool(),
|
r.listDirTool(),
|
||||||
r.grepTool(),
|
r.grepTool(),
|
||||||
r.findFilesTool(),
|
r.findFilesTool(),
|
||||||
r.getDiffTool(),
|
r.getDiffTool(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolbox builds the reviewer's toolbox: the read-only repo tools, plus the
|
||||||
|
// delegate_investigation tool when a worker model is configured.
|
||||||
|
func (r *repoFS) toolbox() (*llm.Toolbox, error) {
|
||||||
|
box := llm.NewToolbox("gadfly")
|
||||||
|
tools := r.fsTools()
|
||||||
|
if r.worker != nil {
|
||||||
|
tools = append(tools, r.delegateTool())
|
||||||
|
}
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
if err := box.Add(t); err != nil {
|
if err := box.Add(t); err != nil {
|
||||||
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
|
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
|
||||||
@@ -114,6 +124,18 @@ func (r *repoFS) toolbox() (*llm.Toolbox, error) {
|
|||||||
return box, nil
|
return box, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workerToolbox is the toolbox handed to a delegated worker sub-agent: the
|
||||||
|
// read-only repo tools only (no delegate tool — workers don't sub-delegate).
|
||||||
|
func (r *repoFS) workerToolbox() (*llm.Toolbox, error) {
|
||||||
|
box := llm.NewToolbox("gadfly-worker")
|
||||||
|
for _, t := range r.fsTools() {
|
||||||
|
if err := box.Add(t); err != nil {
|
||||||
|
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return box, nil
|
||||||
|
}
|
||||||
|
|
||||||
type readFileArgs struct {
|
type readFileArgs struct {
|
||||||
Path string `json:"path" description:"Repository-relative path of the file to read, e.g. pkg/logic/agentexec/pipeline.go"`
|
Path string `json:"path" description:"Repository-relative path of the file to read, e.g. pkg/logic/agentexec/pipeline.go"`
|
||||||
StartLine int `json:"start_line,omitempty" description:"Optional 1-based line to start from (default 1)."`
|
StartLine int `json:"start_line,omitempty" description:"Optional 1-based line to start from (default 1)."`
|
||||||
|
|||||||
+140
-15
@@ -31,13 +31,25 @@
|
|||||||
# COMMENT_ID comment id, for the 👀 reaction (issue_comment only)
|
# COMMENT_ID comment id, for the 👀 reaction (issue_comment only)
|
||||||
# ACTOR github.actor (the user who triggered)
|
# ACTOR github.actor (the user who triggered)
|
||||||
# Optional config:
|
# Optional config:
|
||||||
# OLLAMA_REVIEW_MODELS comma-separated model ids (default below)
|
# GADFLY_MODELS comma-separated model ids/specs (alias: OLLAMA_REVIEW_MODELS)
|
||||||
|
# GADFLY_PROVIDER majordomo provider for bare model ids (default ollama-cloud;
|
||||||
|
# e.g. "ollama" local, "openai", "anthropic", "google")
|
||||||
|
# GADFLY_BASE_URL override backend endpoint (OpenAI/Ollama-compatible servers)
|
||||||
|
# GADFLY_API_KEY provider key (else provider's standard env: OPENAI_API_KEY, …)
|
||||||
|
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
|
||||||
|
# "claude-code"/"claude-code/<model>"); Pro/Max subscription
|
||||||
|
# token from `claude setup-token`. Else ANTHROPIC_API_KEY.
|
||||||
# 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"
|
||||||
|
# GADFLY_FINDINGS_URL optional gadfly-reports store base URL; set to POST the run +
|
||||||
|
# findings for model-quality tracking (off when empty)
|
||||||
|
# GADFLY_FINDINGS_TOKEN optional bearer token for the gadfly-reports store
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
DEFAULT_MODELS="qwen3-coder:480b-cloud,gpt-oss:120b-cloud"
|
# One model by default: the specialist suite already provides breadth, so a
|
||||||
|
# multi-model default would multiply cost (models × specialists × 2 passes).
|
||||||
|
DEFAULT_MODELS="qwen3-coder:480b-cloud"
|
||||||
TRIGGER_PHRASE="${GADFLY_TRIGGER_PHRASE:-@gadfly review}"
|
TRIGGER_PHRASE="${GADFLY_TRIGGER_PHRASE:-@gadfly review}"
|
||||||
SCRIPTS_DIR="/app/scripts"
|
SCRIPTS_DIR="/app/scripts"
|
||||||
WORKDIR="${WORKDIR:-/tmp/gadfly}"
|
WORKDIR="${WORKDIR:-/tmp/gadfly}"
|
||||||
@@ -118,18 +130,131 @@ log "cloning ${REPO_PATH} @ ${BRANCH}"
|
|||||||
git clone --depth=1 --branch "$BRANCH" "$CLONE_URL" "$REPO_DIR" 2>/dev/null \
|
git clone --depth=1 --branch "$BRANCH" "$CLONE_URL" "$REPO_DIR" 2>/dev/null \
|
||||||
|| die "clone of ${REPO_PATH}@${BRANCH} failed"
|
|| die "clone of ${REPO_PATH}@${BRANCH} failed"
|
||||||
|
|
||||||
# --- review once per model -------------------------------------------------
|
# --- findings telemetry (optional) -----------------------------------------
|
||||||
MODELS="${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}"
|
# Plumb the run context to the binary (inherited through run.sh and the gadfly
|
||||||
log "models: ${MODELS}"
|
# process env). The binary's emit is OFF unless GADFLY_FINDINGS_URL is set; when
|
||||||
IFS=',' read -ra ARR <<< "$MODELS" || true
|
# set it POSTs the run + its findings to a gadfly-reports store. GADFLY_FINDINGS_URL /
|
||||||
for raw in "${ARR[@]}"; do
|
# GADFLY_FINDINGS_TOKEN come from the consumer's stub env and are re-exported so
|
||||||
m="$(echo "$raw" | tr -d '[:space:]')"
|
# they reach the binary even if unset (empty => disabled).
|
||||||
[ -z "$m" ] && continue
|
export GADFLY_REPO="$REPO_PATH"
|
||||||
log "::: reviewing with ${m}"
|
export GADFLY_PR="$PR"
|
||||||
PROVIDER=ollama \
|
export GADFLY_FINDINGS_URL="${GADFLY_FINDINGS_URL:-}"
|
||||||
MODEL="$m" \
|
export GADFLY_FINDINGS_TOKEN="${GADFLY_FINDINGS_TOKEN:-}"
|
||||||
GADFLY_BIN="/usr/local/bin/gadfly" \
|
|
||||||
GADFLY_REPO_DIR="$REPO_DIR" \
|
# --- review once per model, with per-provider concurrency -------------------
|
||||||
bash "${SCRIPTS_DIR}/run.sh" || log "model ${m} failed (continuing)"
|
# GADFLY_MODELS is the provider-agnostic name; OLLAMA_REVIEW_MODELS is a
|
||||||
|
# back-compat alias. GADFLY_PROVIDER / GADFLY_BASE_URL / GADFLY_API_KEY and any
|
||||||
|
# provider key envs (OPENAI_API_KEY, …) are inherited by run.sh and the binary.
|
||||||
|
#
|
||||||
|
# Concurrency: each PROVIDER is its own lane and lanes run in PARALLEL, so a fast
|
||||||
|
# cloud provider isn't stuck behind a slow local box. Within a lane, at most
|
||||||
|
# `cap` models run at once. cap = GADFLY_PROVIDER_CONCURRENCY's "provider=N"
|
||||||
|
# entry, else GADFLY_CONCURRENCY (default 1). A model's provider is the spec's
|
||||||
|
# first path segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER /
|
||||||
|
# ollama-cloud for a bare id. Default (cap 1) keeps a single-provider pool fully
|
||||||
|
# sequential, exactly as before.
|
||||||
|
MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"
|
||||||
|
DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"
|
||||||
|
|
||||||
|
provider_of() { case "$1" in */*) echo "${1%%/*}";; *) echo "${GADFLY_PROVIDER:-ollama-cloud}";; esac; }
|
||||||
|
|
||||||
|
# Per-model status file path for the live board. The model id can contain '/'
|
||||||
|
# and ':' (e.g. m1/qwen3:14b), so sanitize to a flat filename; the JSON inside
|
||||||
|
# carries the real model/provider, so this just needs to be unique per model.
|
||||||
|
STATUS_DIR="${WORKDIR}/status"
|
||||||
|
status_file_for() { echo "${STATUS_DIR}/$(echo "$1" | tr -c '[:alnum:]._-' '_').json"; }
|
||||||
|
|
||||||
|
provider_cap() { # provider -> concurrency (override map "p=N,...", else default)
|
||||||
|
local p="$1" item k v
|
||||||
|
IFS=',' read -ra _caps <<< "${GADFLY_PROVIDER_CONCURRENCY:-}"
|
||||||
|
for item in "${_caps[@]}"; do
|
||||||
|
k="$(echo "${item%%=*}" | tr -d '[:space:]')"
|
||||||
|
v="$(echo "${item#*=}" | tr -d '[:space:]')"
|
||||||
|
if [ "$k" = "$p" ] && [ -n "$v" ]; then echo "$v"; return; fi
|
||||||
|
done
|
||||||
|
echo "$DEFAULT_CONC"
|
||||||
|
}
|
||||||
|
|
||||||
|
review_one() {
|
||||||
|
local sf=""
|
||||||
|
[ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$1")"
|
||||||
|
PROVIDER=ollama MODEL="$1" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \
|
||||||
|
GADFLY_STATUS_FILE="$sf" \
|
||||||
|
bash "${SCRIPTS_DIR}/run.sh" || log "model $1 failed (continuing)"
|
||||||
|
# If the binary never wrote real status (run.sh skipped it: empty diff, no key,
|
||||||
|
# binary missing), the pre-seed stays {started:0, done:false} and the board
|
||||||
|
# would show this model "waiting to start" forever and never reach N/N. Mark
|
||||||
|
# such a never-started file done so the board can complete. The binary stamps a
|
||||||
|
# nonzero `started`, so that reliably distinguishes "ran" from "skipped".
|
||||||
|
if [ -n "$sf" ] && [ -f "$sf" ] && [ "$(jq -r '.started // 0' "$sf" 2>/dev/null)" = "0" ]; then
|
||||||
|
tmp="$(jq '.done = true' "$sf" 2>/dev/null)" && printf '%s' "$tmp" > "$sf"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize the model list (trim, drop blanks) into MODEL_LIST.
|
||||||
|
IFS=',' read -ra _raw <<< "$MODELS" || true
|
||||||
|
MODEL_LIST=()
|
||||||
|
for raw in "${_raw[@]}"; do m="$(echo "$raw" | tr -d '[:space:]')"; [ -n "$m" ] && MODEL_LIST+=("$m"); done
|
||||||
|
|
||||||
|
# Distinct providers, in first-seen order (no associative arrays — portable).
|
||||||
|
PROVIDERS=""
|
||||||
|
for m in "${MODEL_LIST[@]}"; do
|
||||||
|
p="$(provider_of "$m")"
|
||||||
|
case " $PROVIDERS " in *" $p "*) ;; *) PROVIDERS="${PROVIDERS}${PROVIDERS:+ }$p" ;; esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
run_lane() { # $1=provider: run its models, at most `cap` at a time
|
||||||
|
local p="$1" cap inflight=0 m
|
||||||
|
cap="$(provider_cap "$p")"; [ "$cap" -ge 1 ] 2>/dev/null || cap=1
|
||||||
|
local mine=()
|
||||||
|
for m in "${MODEL_LIST[@]}"; do [ "$(provider_of "$m")" = "$p" ] && mine+=("$m"); done
|
||||||
|
log "lane ${p}: cap ${cap}; models: ${mine[*]}"
|
||||||
|
for m in "${mine[@]}"; do
|
||||||
|
review_one "$m" &
|
||||||
|
inflight=$((inflight+1))
|
||||||
|
if [ "$inflight" -ge "$cap" ]; then wait -n 2>/dev/null || wait; inflight=$((inflight-1)); fi
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- live status board (optional, default on) ------------------------------
|
||||||
|
# Each model process publishes per-lens progress to STATUS_DIR/<model>.json; a
|
||||||
|
# background renderer (status-board.sh) upserts ONE consolidated PR comment so
|
||||||
|
# progress across all models/lenses is visible at a glance — and a watcher can
|
||||||
|
# tell when the whole swarm is finished. Advisory/best-effort; the per-model
|
||||||
|
# findings still land in each model's own comment. Disable with
|
||||||
|
# GADFLY_STATUS_BOARD=0.
|
||||||
|
BOARD_PID=""
|
||||||
|
if [ "${GADFLY_STATUS_BOARD:-1}" != "0" ]; then
|
||||||
|
rm -rf "$STATUS_DIR"; mkdir -p "$STATUS_DIR"
|
||||||
|
# Pre-seed every model as queued so the board shows the full swarm from t=0,
|
||||||
|
# even models still waiting on their provider lane's concurrency cap. Each
|
||||||
|
# binary overwrites its own file with real per-lens detail once it starts.
|
||||||
|
for m in "${MODEL_LIST[@]}"; do
|
||||||
|
jq -n --arg model "$m" --arg provider "$(provider_of "$m")" \
|
||||||
|
'{model:$model, provider:$provider, started:0, updated:0, done:false, lenses:[]}' \
|
||||||
|
> "$(status_file_for "$m")" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
GITEA_API="$GITEA_API" GITEA_TOKEN="$GITEA_TOKEN" PR="$PR" GADFLY_STATUS_DIR="$STATUS_DIR" \
|
||||||
|
bash "${SCRIPTS_DIR}/status-board.sh" &
|
||||||
|
BOARD_PID=$!
|
||||||
|
log "status board started (pid ${BOARD_PID})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "providers: ${PROVIDERS:-none}"
|
||||||
|
# Each provider lane runs in parallel; cap is enforced within each lane. Track
|
||||||
|
# the lane PIDs so we wait ONLY for the review work — not the status board,
|
||||||
|
# which intentionally runs until we signal it below.
|
||||||
|
LANE_PIDS=()
|
||||||
|
for p in $PROVIDERS; do
|
||||||
|
run_lane "$p" &
|
||||||
|
LANE_PIDS+=("$!")
|
||||||
|
done
|
||||||
|
[ "${#LANE_PIDS[@]}" -gt 0 ] && wait "${LANE_PIDS[@]}"
|
||||||
|
|
||||||
|
# Reviews are done: signal the board to render the final state once and exit.
|
||||||
|
if [ -n "$BOARD_PID" ]; then
|
||||||
|
touch "${STATUS_DIR}/.done" 2>/dev/null || true
|
||||||
|
wait "$BOARD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
log "done"
|
log "done"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Optional per-repo Gadfly config. Place at the repo ROOT as `.gadfly.yml`
|
||||||
|
# (or point GADFLY_CONFIG at it). It's read from the PR's checked-out tree, so
|
||||||
|
# it's version-controlled and reviewed like any other file.
|
||||||
|
#
|
||||||
|
# Precedence for specialist DEFINITIONS: built-ins < this file < GADFLY_SPECIALIST_* env.
|
||||||
|
# Precedence for SELECTION: GADFLY_SPECIALISTS env > this file's `specialists:` > default suite.
|
||||||
|
|
||||||
|
# Which specialists run. Omit this key to use the default suite
|
||||||
|
# (security, correctness, maintainability, performance, error-handling).
|
||||||
|
# Built-in opt-ins you can add: tests, docs, conventions, improvements. Or "all".
|
||||||
|
specialists:
|
||||||
|
- security
|
||||||
|
- correctness
|
||||||
|
- maintainability
|
||||||
|
- tests
|
||||||
|
- migrations # a custom one, defined below
|
||||||
|
|
||||||
|
# Add new specialists, or override a built-in by reusing its name (e.g. give
|
||||||
|
# `security` a repo-specific focus). `focus` is appended to the base reviewer
|
||||||
|
# prompt as that lens's instruction.
|
||||||
|
define:
|
||||||
|
- name: migrations
|
||||||
|
title: "🗃️ DB migrations"
|
||||||
|
focus: >
|
||||||
|
Review database schema migrations for destructive operations (DROP/ALTER
|
||||||
|
that loses data), missing or unindexed foreign keys, non-idempotent steps,
|
||||||
|
and changes that would lock a large table on deploy. Check that any new
|
||||||
|
column added to a domain struct is wired through every storage layer.
|
||||||
|
- name: security
|
||||||
|
title: "🔒 Security (house rules)"
|
||||||
|
focus: >
|
||||||
|
In addition to the usual security review, this repo requires: all web
|
||||||
|
routes use the auth middleware, no secrets in logs, and all external HTTP
|
||||||
|
calls set a timeout. Flag any violation.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Example consumer workflows
|
||||||
|
|
||||||
|
Each file here is a complete, copy-paste **stub workflow**. Pick the one that matches your
|
||||||
|
setup, copy it to `.gitea/workflows/adversarial-review.yml` in the repo you want reviewed, and
|
||||||
|
set the secrets/vars it references. Gadfly is advisory only — it never blocks a merge.
|
||||||
|
|
||||||
|
| File | Backend | Needs |
|
||||||
|
|------|---------|-------|
|
||||||
|
| [`reusable.yml`](reusable.yml) | **slimmest stub** — calls Gadfly's reusable workflow and inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite), forwarding only the secrets it needs (least privilege, not `secrets: inherit`); the stub keeps a cloud-only `models:` override so it runs with just the Ollama key (drop it + add the Claude token to get the full default) | 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_<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`) |
|
||||||
|
| [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — |
|
||||||
|
|
||||||
|
Common to all:
|
||||||
|
- **Triggers:** new/reopened/ready non-draft PR (auto), `@gadfly review` comment (allowed users),
|
||||||
|
or manual `workflow_dispatch` with a `pr_number`.
|
||||||
|
- `GITEA_TOKEN` is provided automatically; comments post as `gitea-actions`.
|
||||||
|
- Tested backends are the **Ollama** ones; OpenAI/Anthropic/Google are wired via majordomo but
|
||||||
|
untested. See the repo [README](../README.md#models--providers) for the full config reference
|
||||||
|
and the honest tested/untested status.
|
||||||
|
|
||||||
|
> **Gitea note:** repo `vars`/`secrets` are not auto-exposed as env — anything you reference
|
||||||
|
> via `${{ vars.X }}` / `${{ secrets.X }}` must appear in the step's `env:` block (already wired
|
||||||
|
> in these examples).
|
||||||
@@ -33,6 +33,13 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
|
# Security: only trusted users may trigger a secret-bearing run via a PR
|
||||||
|
# comment (pull_request + workflow_dispatch are already trusted). 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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -43,6 +50,58 @@ jobs:
|
|||||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
OLLAMA_REVIEW_MODELS: ${{ vars.OLLAMA_REVIEW_MODELS }}
|
OLLAMA_REVIEW_MODELS: ${{ vars.OLLAMA_REVIEW_MODELS }}
|
||||||
GADFLY_ALLOWED_USERS: ${{ vars.GADFLY_ALLOWED_USERS }}
|
GADFLY_ALLOWED_USERS: ${{ vars.GADFLY_ALLOWED_USERS }}
|
||||||
|
# Specialist suite (optional). Empty = default suite
|
||||||
|
# (security,correctness,maintainability,performance,error-handling).
|
||||||
|
# csv to choose; "all" for everything; or define custom ones via a repo
|
||||||
|
# .gadfly.yml / GADFLY_SPECIALIST_<NAME>. See README "Specialists".
|
||||||
|
GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS }}
|
||||||
|
# Lens fan-out (optional; default 1 = lenses run sequentially within a
|
||||||
|
# model). Raise it to run a model's lenses concurrently so each model
|
||||||
|
# posts its comment sooner. Total in-flight requests = (models at once)
|
||||||
|
# × (lenses at once), so to fan out without oversubscribing a backend,
|
||||||
|
# keep its model cap low and raise its lens cap. Per-provider configurable
|
||||||
|
# via GADFLY_PROVIDER_LENS_CONCURRENCY (same lanes as the model map):
|
||||||
|
# GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
|
||||||
|
# GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
|
||||||
|
# GADFLY_LENS_CONCURRENCY: ${{ vars.GADFLY_LENS_CONCURRENCY }}
|
||||||
|
# GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ vars.GADFLY_PROVIDER_LENS_CONCURRENCY }}
|
||||||
|
# Live status board (optional; ON by default): one consolidated comment
|
||||||
|
# showing every model's per-lens progress as it runs. Disable with
|
||||||
|
# GADFLY_STATUS_BOARD=0; tune the refresh with GADFLY_STATUS_POLL_SECS.
|
||||||
|
# GADFLY_STATUS_BOARD: ${{ vars.GADFLY_STATUS_BOARD }}
|
||||||
|
# --- Models & providers (optional; default = Ollama Cloud) ----------
|
||||||
|
# Gadfly is majordomo-powered, so it can target other backends. Set a
|
||||||
|
# provider for bare model ids; point at a different endpoint with a
|
||||||
|
# base URL; supply a key (or the provider's standard env var). Examples:
|
||||||
|
#
|
||||||
|
# Local Ollama daemon (no key):
|
||||||
|
# GADFLY_PROVIDER: ollama
|
||||||
|
# GADFLY_MODELS: qwen2.5-coder:7b
|
||||||
|
# # GADFLY_BASE_URL: http://my-ollama-host:11434 # if not localhost
|
||||||
|
#
|
||||||
|
# OpenAI-compatible endpoint (incl. local Ollama's /v1):
|
||||||
|
# GADFLY_PROVIDER: openai
|
||||||
|
# GADFLY_BASE_URL: http://localhost:11434/v1
|
||||||
|
# GADFLY_MODELS: qwen2.5-coder:7b
|
||||||
|
#
|
||||||
|
# OpenAI / Anthropic / Google (supported via majordomo, UNTESTED — see README):
|
||||||
|
# GADFLY_PROVIDER: openai # then set OPENAI_API_KEY below
|
||||||
|
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
#
|
||||||
|
# Named endpoint aliases (reference as name/model in GADFLY_MODELS).
|
||||||
|
# vars/secrets aren't auto-exposed, so map each one explicitly:
|
||||||
|
# GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }} # "ollama|http://192.168.1.50:11434"
|
||||||
|
# GADFLY_MODELS: bigbox/qwen2.5-coder:7b
|
||||||
|
GADFLY_PROVIDER: ${{ vars.GADFLY_PROVIDER }}
|
||||||
|
GADFLY_BASE_URL: ${{ vars.GADFLY_BASE_URL }}
|
||||||
|
GADFLY_MODELS: ${{ vars.GADFLY_MODELS }}
|
||||||
|
# --- Findings telemetry (optional; OFF by default) ------------------
|
||||||
|
# Set GADFLY_FINDINGS_URL to a gadfly-reports store base URL to POST each run +
|
||||||
|
# its findings for model-quality tracking. Advisory only: failures are
|
||||||
|
# logged to stderr and never affect the review. Add a bearer token if
|
||||||
|
# the store requires auth. (GADFLY_REPO / GADFLY_PR are derived for you.)
|
||||||
|
# GADFLY_FINDINGS_URL: ${{ vars.GADFLY_FINDINGS_URL }}
|
||||||
|
# GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
||||||
PR_BRANCH: ${{ github.head_ref }}
|
PR_BRANCH: ${{ github.head_ref }}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Gadfly reviewing via the Claude Code CLI engine.
|
||||||
|
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
|
||||||
|
#
|
||||||
|
# Instead of a majordomo model, each lens shells out to the bundled `claude` CLI
|
||||||
|
# inside the checked-out repo (it uses its own Read/Grep/Glob tools to verify
|
||||||
|
# findings), then Gadfly runs its usual verdict + recheck + consolidate pipeline.
|
||||||
|
#
|
||||||
|
# Auth: a Pro/Max subscription token from `claude setup-token` (no --bare),
|
||||||
|
# stored as the CLAUDE_CODE_OAUTH_TOKEN secret. Falls back to ANTHROPIC_API_KEY
|
||||||
|
# if you'd rather pay per-token — set only ONE.
|
||||||
|
#
|
||||||
|
# Heads-up: this engine is wired but not yet validated end-to-end here, and using
|
||||||
|
# subscription auth in automated CI is a gray area in Anthropic's terms — read
|
||||||
|
# the README's "Claude Code 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 }}
|
||||||
|
# --- Claude Code engine ---
|
||||||
|
# Pro/Max subscription token (preferred). Or set ANTHROPIC_API_KEY
|
||||||
|
# instead for per-token billing — but never both.
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# bare "claude-code" uses the CLI default model; "claude-code/<model>"
|
||||||
|
# sets --model (sonnet/opus/haiku, or a full id). One comment per entry.
|
||||||
|
GADFLY_MODELS: "claude-code/sonnet"
|
||||||
|
# Optional CLI tuning (defaults are read-only-safe):
|
||||||
|
# GADFLY_CLAUDE_PERMISSION_MODE: plan # read-only; never edits
|
||||||
|
# GADFLY_CLAUDE_ALLOWED_TOOLS: "Read,Grep,Glob"
|
||||||
|
# GADFLY_CLAUDE_EXTRA_ARGS: "--max-turns 30"
|
||||||
|
# Alternate backend (EXAMPLE ONLY, not validated): point Claude Code at
|
||||||
|
# an Anthropic-API-compatible proxy (e.g. claude-code-router / LiteLLM in
|
||||||
|
# front of Ollama) to run Ollama models THROUGH the CC harness. The
|
||||||
|
# subprocess env forwards ANTHROPIC_*, so just set these instead of the
|
||||||
|
# token above. Tool-use support depends on the proxy/backend.
|
||||||
|
# ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }}
|
||||||
|
# ANTHROPIC_AUTH_TOKEN: ${{ secrets.ANTHROPIC_AUTH_TOKEN }}
|
||||||
|
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 }}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Gadfly with named ENDPOINT ALIASES — review with several backends at once,
|
||||||
|
# each posting its own comment. Copy to .gitea/workflows/adversarial-review.yml.
|
||||||
|
#
|
||||||
|
# GADFLY_ENDPOINT_<NAME>="<provider>|<base-url>[|<key>]" registers a provider you
|
||||||
|
# can then reference as "<name>/<model>" (NAME lowercases: BIGBOX -> bigbox).
|
||||||
|
# The base URL is used verbatim, so plaintext http LAN endpoints work.
|
||||||
|
#
|
||||||
|
# provider is ollama / foreman / openai / anthropic / google. "foreman" targets a
|
||||||
|
# foreman queue daemon (https://gitea.stevedudenhoeffer.com/steve/foreman) — native
|
||||||
|
# Ollama on the wire, so just give it the daemon's URL (and optional bearer token).
|
||||||
|
#
|
||||||
|
# Gitea note: vars/secrets aren't auto-exposed as env, so map each alias here.
|
||||||
|
# Suggested repo vars (and a secret when the value carries a token):
|
||||||
|
# GADFLY_ENDPOINT_BIGBOX = ollama|http://192.168.1.50:11434
|
||||||
|
# GADFLY_ENDPOINT_GPU = openai|http://gpu.lan:8000/v1
|
||||||
|
# GADFLY_ENDPOINT_M1 = foreman|http://foreman-m1:8080|<token> (use a secret)
|
||||||
|
|
||||||
|
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 (pull_request + workflow_dispatch are already trusted). 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 }}
|
||||||
|
# --- named endpoints (mapped from repo vars) ---
|
||||||
|
GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }} # "ollama|http://192.168.1.50:11434"
|
||||||
|
GADFLY_ENDPOINT_GPU: ${{ vars.GADFLY_ENDPOINT_GPU }} # "openai|http://gpu.lan:8000/v1"
|
||||||
|
GADFLY_ENDPOINT_M1: ${{ secrets.GADFLY_ENDPOINT_M1 }} # "foreman|http://foreman-m1:8080|<token>"
|
||||||
|
# one reviewer (one comment) per model, across the aliased endpoints:
|
||||||
|
GADFLY_MODELS: "bigbox/qwen2.5-coder:7b,gpu/llama3.1,m1/qwen3:14b"
|
||||||
|
# --- 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 }}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Gadfly using a LOCAL Ollama daemon (no API key needed).
|
||||||
|
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
|
||||||
|
#
|
||||||
|
# The runner must be able to reach the Ollama host. For localhost on the runner,
|
||||||
|
# leave GADFLY_BASE_URL unset; for a LAN box set it to http://<host>:11434.
|
||||||
|
#
|
||||||
|
# Pick a model you've pulled into Ollama (e.g. `ollama pull qwen2.5-coder:7b`).
|
||||||
|
|
||||||
|
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 (pull_request + workflow_dispatch are already trusted). 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 }}
|
||||||
|
# --- local Ollama ---
|
||||||
|
GADFLY_PROVIDER: ollama
|
||||||
|
GADFLY_MODELS: qwen2.5-coder:7b
|
||||||
|
# GADFLY_BASE_URL: http://192.168.1.50:11434 # uncomment for a remote/LAN daemon
|
||||||
|
# --- 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 }}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Gadfly against an OpenAI-COMPATIBLE endpoint.
|
||||||
|
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
|
||||||
|
#
|
||||||
|
# Works for: a local Ollama's OpenAI bridge (http://localhost:11434/v1), an
|
||||||
|
# in-house gateway, OpenRouter, vLLM, LM Studio, etc. This is the same code path
|
||||||
|
# the real OpenAI API uses, so it's a free way to exercise the OpenAI provider.
|
||||||
|
#
|
||||||
|
# Set GADFLY_API_KEY (or OPENAI_API_KEY) — Ollama ignores it, but most gateways
|
||||||
|
# require some value.
|
||||||
|
|
||||||
|
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 (pull_request + workflow_dispatch are already trusted). 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 }}
|
||||||
|
# --- OpenAI-compatible endpoint ---
|
||||||
|
GADFLY_PROVIDER: openai
|
||||||
|
GADFLY_BASE_URL: http://localhost:11434/v1 # e.g. local Ollama /v1, or your gateway
|
||||||
|
GADFLY_API_KEY: ${{ secrets.OPENAI_API_KEY }} # any non-empty value for Ollama
|
||||||
|
GADFLY_MODELS: qwen2.5-coder:7b
|
||||||
|
# --- 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 }}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine
|
||||||
|
# (sonnet/opus/opus:max), 5-lens suite. That default needs BOTH
|
||||||
|
# OLLAMA_CLOUD_API_KEY and CLAUDE_CODE_OAUTH_TOKEN. This example overrides
|
||||||
|
# `models:` to a cloud-only set so it works with just OLLAMA_CLOUD_API_KEY —
|
||||||
|
# delete that override (and forward the Claude token) to inherit the full default.
|
||||||
|
#
|
||||||
|
# Forward ONLY the secrets the reviewer uses (least privilege) — see the
|
||||||
|
# `secrets:` block below. GITEA_TOKEN is automatic. `secrets: inherit` also works
|
||||||
|
# but hands the reusable EVERY secret in your repo (registry/deploy/db creds the
|
||||||
|
# review never touches), so prefer the explicit form. Pin @<ref>: use the @v1
|
||||||
|
# release tag (a curated pointer moved on deliberate releases) for auto-updating
|
||||||
|
# stability, or a full @<sha> for an immutable pin. Avoid @main — it moves on
|
||||||
|
# every push and would change what runs with your forwarded secrets.
|
||||||
|
#
|
||||||
|
# For custom named endpoints (GADFLY_ENDPOINT_<NAME>) 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.event.issue.pull_request && github.actor == 'your-username')
|
||||||
|
# @v1 = curated release tag (auto-updates on releases); swap for a full @<sha>
|
||||||
|
# if you want an immutable pin. Don't use @main (moves on every push).
|
||||||
|
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1
|
||||||
|
# Forward ONLY what the reviewer needs. Add provider keys you use
|
||||||
|
# (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GADFLY_API_KEY) and/or
|
||||||
|
# GADFLY_ENDPOINT_M1/M5; drop the findings ones if you don't run telemetry.
|
||||||
|
secrets:
|
||||||
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
|
# CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
# GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
|
||||||
|
# GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
|
||||||
|
with:
|
||||||
|
# Cloud-only override so this works with just OLLAMA_CLOUD_API_KEY. Delete
|
||||||
|
# this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full
|
||||||
|
# default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses).
|
||||||
|
models: "minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud"
|
||||||
|
# Other inputs inherit the default (5-lens suite, concurrency, 90-min cap);
|
||||||
|
# override any of them here (specialists, provider, base_url, timeout_secs…).
|
||||||
|
allowed_users: "your-username"
|
||||||
@@ -2,4 +2,36 @@ module gitea.stevedudenhoeffer.com/steve/gadfly
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b
|
require (
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.20.0 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
google.golang.org/api v0.286.0 // indirect
|
||||||
|
google.golang.org/genai v1.62.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
|
||||||
|
google.golang.org/grpc v1.81.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,2 +1,88 @@
|
|||||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b h1:/pglCqQW02kV2p9tKyQpIJoXZK2p7LKLeDCZL/V26MM=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
|
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||||
|
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462 h1:1crjE1YkWHLZ91tUDOxN/Y5cuOnJ56e0U9UADoFfEPY=
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||||
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
|
||||||
|
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
|
||||||
|
google.golang.org/genai v1.62.0 h1:PaBju84orf4Vbcc6OfHe4vxhxhjwulKTgOpEc3iIc00=
|
||||||
|
google.golang.org/genai v1.62.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||||
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||||
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+74
-35
@@ -23,8 +23,16 @@
|
|||||||
# GADFLY_REPO_DIR (checked-out repo; default: this script's repo)
|
# GADFLY_REPO_DIR (checked-out repo; default: this script's repo)
|
||||||
# antigravity: `agy` on PATH with credentials already seeded (~/.gemini)
|
# antigravity: `agy` on PATH with credentials already seeded (~/.gemini)
|
||||||
#
|
#
|
||||||
|
# claude-code engine: when MODEL is "claude-code" or "claude-code/<model>" the
|
||||||
|
# binary shells out to the bundled `claude` CLI instead of a majordomo model.
|
||||||
|
# Its auth (CLAUDE_CODE_OAUTH_TOKEN, else ANTHROPIC_API_KEY) and GADFLY_CLAUDE_*
|
||||||
|
# tuning are read straight from the inherited environment — same as the other
|
||||||
|
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
|
||||||
|
#
|
||||||
# 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
|
||||||
|
# entrypoint.sh; empty/unset disables status publishing)
|
||||||
#
|
#
|
||||||
# This script is advisory: it never fails the job for review content. It exits
|
# This script is advisory: it never fails the job for review content. It exits
|
||||||
# non-zero only on a usage/configuration error.
|
# non-zero only on a usage/configuration error.
|
||||||
@@ -42,6 +50,14 @@ MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}"
|
|||||||
MARKER="<!-- gadfly-review:${PROVIDER}:${MODEL} -->"
|
MARKER="<!-- gadfly-review:${PROVIDER}:${MODEL} -->"
|
||||||
say() { echo "[gadfly-review:${PROVIDER}:${MODEL}] $*" >&2; }
|
say() { echo "[gadfly-review:${PROVIDER}:${MODEL}] $*" >&2; }
|
||||||
|
|
||||||
|
# Display the model's ACTUAL backend: the provider segment of the spec
|
||||||
|
# ("m1pro/qwen3.6:35b-mlx" -> "m1pro"); a bare id uses GADFLY_PROVIDER (default
|
||||||
|
# ollama-cloud). This is what the comment header shows, not the run.sh lane.
|
||||||
|
case "$MODEL" in
|
||||||
|
*/*) MODEL_PROVIDER="${MODEL%%/*}" ;;
|
||||||
|
*) MODEL_PROVIDER="${GADFLY_PROVIDER:-ollama-cloud}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# jq is required for payload building / response parsing; install if missing.
|
# jq is required for payload building / response parsing; install if missing.
|
||||||
if ! command -v jq >/dev/null 2>&1; then
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
say "jq not found; attempting install"
|
say "jq not found; attempting install"
|
||||||
@@ -55,6 +71,32 @@ fi
|
|||||||
# binary / agy, not here.)
|
# binary / agy, not here.)
|
||||||
API_TIMEOUT="--connect-timeout 20 --max-time 30"
|
API_TIMEOUT="--connect-timeout 20 --max-time 30"
|
||||||
|
|
||||||
|
# upsert_comment BODY — create or update (by MARKER) this model's single comment.
|
||||||
|
upsert_comment() {
|
||||||
|
local body="$1" post_body existing_id page=1 cmts
|
||||||
|
post_body="$(jq -n --arg b "$body" '{body:$b}')"
|
||||||
|
existing_id=""
|
||||||
|
while [ "$page" -le 10 ]; do
|
||||||
|
cmts="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" || echo '[]')"
|
||||||
|
[ "$(echo "$cmts" | jq 'length')" = "0" ] && break
|
||||||
|
existing_id="$(echo "$cmts" | jq -r --arg m "$MARKER" \
|
||||||
|
'.[] | select(.body != null and (.body | startswith($m))) | .id' | head -n1)"
|
||||||
|
[ -n "$existing_id" ] && break
|
||||||
|
page=$((page+1))
|
||||||
|
done
|
||||||
|
if [ -n "$existing_id" ]; then
|
||||||
|
curl $API_TIMEOUT -sS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
"${GITEA_API}/issues/comments/${existing_id}" -d "$post_body" >/dev/null
|
||||||
|
else
|
||||||
|
curl $API_TIMEOUT -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
"${GITEA_API}/issues/${PR}/comments" -d "$post_body" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# fmt_duration SECONDS -> "1m 23s" / "45s"
|
||||||
|
fmt_duration() { if [ "$1" -ge 60 ]; then echo "$(($1/60))m $(($1%60))s"; else echo "$1s"; fi; }
|
||||||
|
|
||||||
# --- fetch PR context -------------------------------------------------------
|
# --- fetch PR context -------------------------------------------------------
|
||||||
say "fetching PR #${PR} context"
|
say "fetching PR #${PR} context"
|
||||||
DIFF="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}.diff" || true)"
|
DIFF="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}.diff" || true)"
|
||||||
@@ -81,17 +123,33 @@ SYS="$(cat "${SCRIPT_DIR}/system-prompt.txt")"
|
|||||||
USR="$(printf 'PR #%s: %s\n\nDescription:\n%s\n\nUnified diff to review:\n```diff\n%s\n```%s' \
|
USR="$(printf 'PR #%s: %s\n\nDescription:\n%s\n\nUnified diff to review:\n```diff\n%s\n```%s' \
|
||||||
"$PR" "$TITLE" "$BODY" "$DIFF" "$TRUNC_NOTE")"
|
"$PR" "$TITLE" "$BODY" "$DIFF" "$TRUNC_NOTE")"
|
||||||
|
|
||||||
|
# --- announce start (placeholder comment) -----------------------------------
|
||||||
|
START_TS="$(date +%s)"
|
||||||
|
say "starting review with ${MODEL}"
|
||||||
|
upsert_comment "$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n⏳ Reviewing… this comment will update with findings and run time.' \
|
||||||
|
"$MARKER" "$MODEL" "$MODEL_PROVIDER")"
|
||||||
|
|
||||||
# --- call the model ---------------------------------------------------------
|
# --- call the model ---------------------------------------------------------
|
||||||
REVIEW=""
|
REVIEW=""
|
||||||
case "$PROVIDER" in
|
case "$PROVIDER" in
|
||||||
ollama)
|
ollama)
|
||||||
# Agentic lane: hand off to the cmd/gadfly binary, which runs a tool-using
|
# Agentic lane: hand off to the cmd/gadfly binary, which runs a tool-using
|
||||||
# agent over the checked-out repo so it can verify findings instead of
|
# agent over the checked-out repo so it can verify findings instead of
|
||||||
# guessing from the diff. The workflow builds the binary and exports
|
# guessing from the diff. The reviewer is majordomo-powered, so GADFLY_PROVIDER
|
||||||
# GADFLY_BIN + GADFLY_REPO_DIR; we fall back to sane defaults for a
|
# selects the backend (default ollama-cloud); local Ollama, OpenAI, Anthropic,
|
||||||
# local run.
|
# Google and OpenAI/Ollama-compatible endpoints all work — see the README.
|
||||||
if [ -z "${OLLAMA_CLOUD_API_KEY:-}" ]; then
|
|
||||||
REVIEW="⚠️ \`OLLAMA_CLOUD_API_KEY\` is not configured; this reviewer was skipped."
|
# Back-compat: map the consumer's OLLAMA_CLOUD_API_KEY secret onto the
|
||||||
|
# OLLAMA_API_KEY env the ollama-cloud provider reads.
|
||||||
|
if [ -n "${OLLAMA_CLOUD_API_KEY:-}" ] && [ -z "${OLLAMA_API_KEY:-}" ]; then
|
||||||
|
export OLLAMA_API_KEY="$OLLAMA_CLOUD_API_KEY"
|
||||||
|
fi
|
||||||
|
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
|
||||||
|
|
||||||
|
# Only the default cloud provider strictly needs a key up front; local Ollama
|
||||||
|
# and other providers either need none or read their own standard env var.
|
||||||
|
if [ "$GADFLY_PROVIDER_EFF" = "ollama-cloud" ] && [ -z "${OLLAMA_API_KEY:-}" ] && [ -z "${GADFLY_API_KEY:-}" ]; then
|
||||||
|
REVIEW="⚠️ No Ollama Cloud key configured (set \`OLLAMA_CLOUD_API_KEY\`) and \`GADFLY_PROVIDER\` is the default \`ollama-cloud\`; this reviewer was skipped."
|
||||||
else
|
else
|
||||||
BIN="${GADFLY_BIN:-gadfly}"
|
BIN="${GADFLY_BIN:-gadfly}"
|
||||||
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then
|
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then
|
||||||
@@ -101,8 +159,9 @@ case "$PROVIDER" in
|
|||||||
DIFF_FILE="$(mktemp)"
|
DIFF_FILE="$(mktemp)"
|
||||||
ERR_FILE="${DIFF_FILE}.err"
|
ERR_FILE="${DIFF_FILE}.err"
|
||||||
printf '%s' "$FULL_DIFF" > "$DIFF_FILE"
|
printf '%s' "$FULL_DIFF" > "$DIFF_FILE"
|
||||||
|
# GADFLY_PROVIDER / GADFLY_BASE_URL / GADFLY_API_KEY and provider key
|
||||||
|
# envs (OPENAI_API_KEY, …) are inherited from the process environment.
|
||||||
REVIEW="$(
|
REVIEW="$(
|
||||||
OLLAMA_API_KEY="$OLLAMA_CLOUD_API_KEY" \
|
|
||||||
GADFLY_MODEL="$MODEL" \
|
GADFLY_MODEL="$MODEL" \
|
||||||
GADFLY_REPO_DIR="$REPO_DIR" \
|
GADFLY_REPO_DIR="$REPO_DIR" \
|
||||||
GADFLY_DIFF_FILE="$DIFF_FILE" \
|
GADFLY_DIFF_FILE="$DIFF_FILE" \
|
||||||
@@ -110,6 +169,7 @@ case "$PROVIDER" in
|
|||||||
GADFLY_TITLE="$TITLE" \
|
GADFLY_TITLE="$TITLE" \
|
||||||
GADFLY_BODY="$BODY" \
|
GADFLY_BODY="$BODY" \
|
||||||
GADFLY_MAX_DIFF_CHARS="$MAX_DIFF_CHARS" \
|
GADFLY_MAX_DIFF_CHARS="$MAX_DIFF_CHARS" \
|
||||||
|
GADFLY_STATUS_FILE="${GADFLY_STATUS_FILE:-}" \
|
||||||
"$BIN" 2>"$ERR_FILE"
|
"$BIN" 2>"$ERR_FILE"
|
||||||
)"
|
)"
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -141,31 +201,10 @@ $(tail -c 1500 agy.err 2>/dev/null)
|
|||||||
say "unknown provider: ${PROVIDER}"; exit 1 ;;
|
say "unknown provider: ${PROVIDER}"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# --- assemble comment -------------------------------------------------------
|
# --- assemble + post final comment (with run time) --------------------------
|
||||||
COMMENT="$(printf '%s\n### 🔭 Adversarial review — `%s` (%s)\n\n%s\n\n<sub>Automated adversarial review. Advisory only — does not block merge.</sub>' \
|
ELAPSED="$(( $(date +%s) - START_TS ))"
|
||||||
"$MARKER" "$MODEL" "$PROVIDER" "$REVIEW")"
|
DUR="$(fmt_duration "$ELAPSED")"
|
||||||
POST_BODY="$(jq -n --arg b "$COMMENT" '{body:$b}')"
|
COMMENT="$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n%s\n\n<sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in %s</sub>' \
|
||||||
|
"$MARKER" "$MODEL" "$MODEL_PROVIDER" "$REVIEW" "$DUR")"
|
||||||
# --- upsert by marker -------------------------------------------------------
|
upsert_comment "$COMMENT"
|
||||||
EXISTING_ID=""
|
say "done in ${DUR}"
|
||||||
page=1
|
|
||||||
while [ "$page" -le 10 ]; do
|
|
||||||
CMTS="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" || echo '[]')"
|
|
||||||
[ "$(echo "$CMTS" | jq 'length')" = "0" ] && break
|
|
||||||
EXISTING_ID="$(echo "$CMTS" | jq -r --arg m "$MARKER" \
|
|
||||||
'.[] | select(.body != null and (.body | startswith($m))) | .id' | head -n1)"
|
|
||||||
[ -n "$EXISTING_ID" ] && break
|
|
||||||
page=$((page+1))
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -n "$EXISTING_ID" ]; then
|
|
||||||
say "updating existing comment ${EXISTING_ID}"
|
|
||||||
curl $API_TIMEOUT -sS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
|
||||||
"${GITEA_API}/issues/comments/${EXISTING_ID}" -d "$POST_BODY" >/dev/null
|
|
||||||
else
|
|
||||||
say "creating new comment"
|
|
||||||
curl $API_TIMEOUT -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
|
||||||
"${GITEA_API}/issues/${PR}/comments" -d "$POST_BODY" >/dev/null
|
|
||||||
fi
|
|
||||||
say "done"
|
|
||||||
|
|||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Live status board for a Gadfly review.
|
||||||
|
#
|
||||||
|
# Each model process (the cmd/gadfly binary) publishes its per-lens progress to
|
||||||
|
# $GADFLY_STATUS_DIR/<model>.json as lenses go queued -> running -> finished.
|
||||||
|
# This script polls that directory and upserts ONE consolidated PR comment that
|
||||||
|
# aggregates every model's per-lens status, so a human (or an agent watching the
|
||||||
|
# PR) can see the whole swarm's progress at a glance and know when it's done —
|
||||||
|
# instead of staring at N separate "⏳ Reviewing…" placeholders.
|
||||||
|
#
|
||||||
|
# It is advisory and best-effort: a failed render/post is logged and retried on
|
||||||
|
# the next tick; nothing here can fail the review or block a merge. It runs in
|
||||||
|
# the background from entrypoint.sh and exits once the $GADFLY_STATUS_DIR/.done
|
||||||
|
# sentinel appears (the entrypoint touches it after all model lanes finish),
|
||||||
|
# after one final render.
|
||||||
|
#
|
||||||
|
# Required env:
|
||||||
|
# GITEA_API https://HOST/api/v1/repos/OWNER/REPO
|
||||||
|
# GITEA_TOKEN token with repo write access (posts the comment)
|
||||||
|
# PR pull request number
|
||||||
|
# GADFLY_STATUS_DIR directory holding the per-model <model>.json files
|
||||||
|
# Optional:
|
||||||
|
# GADFLY_STATUS_POLL_SECS render/upsert interval (default 12)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
: "${GITEA_API:?GITEA_API required}"
|
||||||
|
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
||||||
|
: "${PR:?PR required}"
|
||||||
|
: "${GADFLY_STATUS_DIR:?GADFLY_STATUS_DIR required}"
|
||||||
|
|
||||||
|
POLL="${GADFLY_STATUS_POLL_SECS:-12}"
|
||||||
|
# Guard against a non-numeric poll interval: with `set -uo pipefail` (no set -e)
|
||||||
|
# a bad `sleep "$POLL"` would fail silently and the `while :` loop would spin,
|
||||||
|
# hammering the Gitea API. Coerce anything non-integer (or <1) back to 12.
|
||||||
|
case "$POLL" in ''|*[!0-9]*) POLL=12 ;; esac
|
||||||
|
[ "$POLL" -ge 1 ] 2>/dev/null || POLL=12
|
||||||
|
DONE_FILE="${GADFLY_STATUS_DIR}/.done"
|
||||||
|
MARKER="<!-- gadfly-status-board -->"
|
||||||
|
API_TIMEOUT="--connect-timeout 20 --max-time 30"
|
||||||
|
BOARD_ID="" # cached comment id, so we PATCH in place instead of re-searching
|
||||||
|
|
||||||
|
say() { echo "[gadfly-status-board] $*" >&2; }
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { say "jq not found; status board disabled"; exit 0; }
|
||||||
|
|
||||||
|
# render_section FILE -> markdown for one model (its header + per-lens bullets).
|
||||||
|
# Reads the JSON the binary writes; tolerates a half-written/missing file by
|
||||||
|
# emitting nothing (jq exits non-zero -> caller skips it this tick).
|
||||||
|
render_section() {
|
||||||
|
jq -r '
|
||||||
|
def icon(state; errored):
|
||||||
|
if state == "finished" then (if errored then "⚠️" else "✅" end)
|
||||||
|
elif state == "running" then "🔄"
|
||||||
|
else "⏸️" end;
|
||||||
|
def lensline:
|
||||||
|
"- " + icon(.state; (.errored // false)) + " **" + .name + "** — " +
|
||||||
|
( if .state == "finished" then (if (.errored // false) then "could not complete" else (.verdict // "done") end)
|
||||||
|
elif .state == "running" then "running"
|
||||||
|
else "queued" end );
|
||||||
|
( [.lenses[] | select(.state == "finished")] | length ) as $fin
|
||||||
|
| ( .lenses | length ) as $tot
|
||||||
|
| ( if .done then "✅ done"
|
||||||
|
elif $tot == 0 then "⏳ waiting to start"
|
||||||
|
else "⏳ " + ($fin|tostring) + "/" + ($tot|tostring) + " lenses" end ) as $sum
|
||||||
|
| "#### `" + .model + "` · " + .provider + " — " + $sum + "\n"
|
||||||
|
+ ( if $tot == 0 then "- ⏸️ _no lenses reported yet_"
|
||||||
|
else ([.lenses[] | lensline] | join("\n")) end )
|
||||||
|
' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# render_body -> the full consolidated comment body (marker + header + sections).
|
||||||
|
render_body() {
|
||||||
|
local f sections="" total=0 done=0 ts
|
||||||
|
shopt -s nullglob
|
||||||
|
local files=("${GADFLY_STATUS_DIR}"/*.json)
|
||||||
|
shopt -u nullglob
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
local sec
|
||||||
|
sec="$(render_section "$f")" || continue
|
||||||
|
[ -z "$sec" ] && continue
|
||||||
|
total=$((total + 1))
|
||||||
|
if [ "$(jq -r 'if .done then 1 else 0 end' "$f" 2>/dev/null)" = "1" ]; then
|
||||||
|
done=$((done + 1))
|
||||||
|
fi
|
||||||
|
sections="${sections}${sec}"$'\n\n'
|
||||||
|
done
|
||||||
|
ts="$(date -u '+%Y-%m-%d %H:%M:%SZ')"
|
||||||
|
if [ "$total" -eq 0 ]; then
|
||||||
|
sections="_Waiting for reviewers to start…_"$'\n'
|
||||||
|
fi
|
||||||
|
printf '%s\n## 🪰 Gadfly — live review status\n\n%d/%d reviewers finished · updated %s\n\n%s\n<sub>Live status board. Findings are posted in each model'\''s own comment. Advisory only — does not block merge.</sub>' \
|
||||||
|
"$MARKER" "$done" "$total" "$ts" "$sections"
|
||||||
|
}
|
||||||
|
|
||||||
|
# find_existing -> id of the board comment if it already exists (paginate by
|
||||||
|
# marker). Used once, to recover the comment across container restarts.
|
||||||
|
find_existing() {
|
||||||
|
local page=1 cmts id
|
||||||
|
while [ "$page" -le 10 ]; do
|
||||||
|
cmts="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" 2>/dev/null || echo '[]')"
|
||||||
|
[ "$(echo "$cmts" | jq 'length' 2>/dev/null || echo 0)" = "0" ] && break
|
||||||
|
id="$(echo "$cmts" | jq -r --arg m "$MARKER" \
|
||||||
|
'.[] | select(.body != null and (.body | startswith($m))) | .id' 2>/dev/null | head -n1)"
|
||||||
|
[ -n "$id" ] && { echo "$id"; return; }
|
||||||
|
page=$((page + 1))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# upsert BODY — PATCH the cached/known board comment, else POST a new one and
|
||||||
|
# cache its id. A failed PATCH (e.g. comment deleted) clears the cache so the
|
||||||
|
# next tick re-discovers or re-creates it.
|
||||||
|
upsert() {
|
||||||
|
local body="$1" post_body resp
|
||||||
|
post_body="$(jq -n --arg b "$body" '{body:$b}')"
|
||||||
|
[ -z "$BOARD_ID" ] && BOARD_ID="$(find_existing)"
|
||||||
|
if [ -n "$BOARD_ID" ]; then
|
||||||
|
if ! curl $API_TIMEOUT -fsS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
"${GITEA_API}/issues/comments/${BOARD_ID}" -d "$post_body" >/dev/null 2>&1; then
|
||||||
|
say "patch of comment ${BOARD_ID} failed; will re-discover"
|
||||||
|
BOARD_ID=""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
resp="$(curl $API_TIMEOUT -fsS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
"${GITEA_API}/issues/${PR}/comments" -d "$post_body" 2>/dev/null || echo '{}')"
|
||||||
|
BOARD_ID="$(echo "$resp" | jq -r '.id // ""' 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
say "starting (poll ${POLL}s, dir ${GADFLY_STATUS_DIR})"
|
||||||
|
while :; do
|
||||||
|
upsert "$(render_body)"
|
||||||
|
[ -f "$DONE_FILE" ] && break
|
||||||
|
sleep "$POLL"
|
||||||
|
done
|
||||||
|
say "done"
|
||||||
+13
-15
@@ -1,6 +1,15 @@
|
|||||||
You are Gadfly, an ADVERSARIAL code reviewer. Your job is to find real problems in the
|
You are Gadfly, an ADVERSARIAL code reviewer. Your job is to find real problems in the
|
||||||
pull request below — not to praise it. A gadfly does not let things slide.
|
pull request below — not to praise it. A gadfly does not let things slide.
|
||||||
|
|
||||||
|
You review through ONE assigned lens (named at the end of this prompt). Evaluate the change
|
||||||
|
THROUGH THAT LENS — that is your job. A separate reviewer independently covers each other
|
||||||
|
angle, so problems outside your lens WILL be caught without you. Do not restate a finding that
|
||||||
|
plainly belongs to another lens just to have something to report — that only creates noise. If
|
||||||
|
your lens turns up nothing material, say so plainly; an honest "nothing in my area" beats
|
||||||
|
re-reporting the obvious bug every other lens already sees. Only exception: if you spot a
|
||||||
|
SEVERE issue clearly outside your lens, you may add ONE line prefixed "Outside my lens:" — but
|
||||||
|
your actual findings must stay within your lens.
|
||||||
|
|
||||||
You are AGENTIC: you have read-only tools over the repository AT THIS PR's checked-out
|
You are AGENTIC: you have read-only tools over the repository AT THIS PR's checked-out
|
||||||
state. USE THEM to verify before you report. Do not review the diff in isolation.
|
state. USE THEM to verify before you report. Do not review the diff in isolation.
|
||||||
- read_file(path[, start_line, limit]) — read a file with line numbers.
|
- read_file(path[, start_line, limit]) — read a file with line numbers.
|
||||||
@@ -19,21 +28,10 @@ Mandatory verification discipline — this is the whole point of giving you tool
|
|||||||
- If you cannot confirm a suspicion with the tools, either drop it or clearly label it
|
- If you cannot confirm a suspicion with the tools, either drop it or clearly label it
|
||||||
"unverified" — do NOT present an unchecked guess as a finding.
|
"unverified" — do NOT present an unchecked guess as a finding.
|
||||||
|
|
||||||
Be skeptical and concrete. Hunt specifically for:
|
Be skeptical and concrete, and apply your assigned lens rigorously. (If your lens leads you to
|
||||||
- Correctness bugs and logic errors introduced by the change.
|
a constant, conversion factor, formula, unit, or threshold, don't trust it because it looks
|
||||||
- SEMANTIC / domain correctness — the failure mode plausible-looking code hides best.
|
reasonable — re-derive the expected value from first principles and compare; plausible-looking
|
||||||
Do NOT trust a constant, conversion factor, formula, unit, or threshold just because
|
magic numbers hide real bugs. Pursue this when it's in your lane, not as a reason to leave it.)
|
||||||
it looks reasonable. Independently RE-DERIVE the expected value from first principles
|
|
||||||
(units, dimensions, edge values) and compare. A magic number that "looks about right"
|
|
||||||
is exactly where real bugs hide (e.g. a linear factor used where it must be squared).
|
|
||||||
- Concurrency issues: data races, deadlocks, unsynchronized shared state, leaked tasks.
|
|
||||||
- Security problems: injection, missing authz/authn, secret leakage, unsafe input handling.
|
|
||||||
- Error handling gaps: ignored errors, swallowed exceptions, missing rollback/cleanup.
|
|
||||||
- Resource leaks: unclosed handles/bodies/files, context/lifetime misuse, unbounded growth.
|
|
||||||
- Missed edge cases: off-by-one, nil/null, empty collection, overflow, zero/negative.
|
|
||||||
- Violations of THIS repo's own conventions. Discover them — do not assume. Read any
|
|
||||||
README / CONTRIBUTING / CLAUDE.md / AGENTS.md / lint config the repo ships, and hold
|
|
||||||
the change to the patterns the surrounding code actually uses.
|
|
||||||
|
|
||||||
Output rules:
|
Output rules:
|
||||||
- Output GitHub-flavored markdown, concise. No filler, no restating the diff.
|
- Output GitHub-flavored markdown, concise. No filler, no restating the diff.
|
||||||
|
|||||||
Reference in New Issue
Block a user