Commit Graph
171 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5.5 dbf78a3b34 ci: move template expressions out of run: shells (signals#47)
CD / Lint / Test / Vet (push) Successful in 12s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Successful in 5s
"Derive image tags" expanded ${{ github.ref }} into the run: body. A
ref name may contain $(...) and the expansion happens before the shell
parses the script, so it now arrives via env: REF instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:33:44 +02:00
mathias c3f8d6c002 ci: run the check job on pull requests (#68) (#69)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 6s
Co-authored-by: Mathias <mthbqv@gmail.com>
v0.16.3
2026-10-05 07:25:38 +00:00
mathias dc127a05b5 Merge pull request 'fix(tools): reject case-variant arg keys and ref-shaped branch names (#65)' (#66) from fix/65-strict-args-branch-names into main
CD / Lint / Test / Vet (push) Successful in 14s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Skipped
v0.16.2
2026-10-05 05:10:51 +00:00
mathiasandClaude Opus 5.5 cd61239451 perf(tools): linear duplicate-key scan keyed on SimpleFold orbit
PR #66 review round 2: the pairwise EqualFold loop was quadratic, 7s of
CPU for ~90k keys under the 1 MiB body cap. foldKey maps each rune to
the smallest member of its unicode.SimpleFold orbit, equal exactly when
strings.EqualFold is, so the scan is a map lookup again. The scaling
test fails on the previous code (7.1s).

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:00 +02:00
mathiasandClaude Opus 5.5 31a3dd3e0e fix(tools): fail closed when the key scan cannot read the input
PR #66 review: json.Decoder.Token() fails on a number outside float64
range (1e999), which json.Unmarshal ignores in an unknown key, so a
decoy key ended the scan and the #65 repro wrote to main. The decoder
now uses UseNumber, and any scan error is a validation error. The
duplicate check folds with strings.EqualFold (as encoding/json does)
instead of ToLower, so long s and the Kelvin sign are caught in nested
objects too.

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:02:27 +02:00
mathiasandClaude Opus 5.5 ae50501ba3 fix(tools): reject case-variant arg keys and ref-shaped branch names
parseArgs now refuses, before any request: two keys equal under
strings.EqualFold in any object (exact duplicates included), and a
top-level key that differs from a schema field only in case. Without
this, encoding/json's case-insensitive last-wins match let
{"branch":"feat/x","Branch":"main"} write to main while a client-side
hook read feat/x.

file_write_branch (branch) and tbd_ship (branch and base) validate the
branch name: ^[A-Za-z0-9][A-Za-z0-9._/-]*$ plus no refs/ prefix, HEAD,
"..", "//", "/.", trailing "/" or ".", or ".lock". refs/heads/main
otherwise created refs/heads/refs/heads/main and wedged API writes to
main. tbd_ship's base is the protection-probe key, so a refs/-prefixed
base would also have read as unprotected.

Closes #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:58:31 +02:00
mathiasandClaude Opus 5.5 9396b4daaa chore(context): resync derived adapters from canonical AGENT.md
CD / Lint / Test / Vet (push) Successful in 28s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 6s
Clears ContextAdapterDrift; canonical changed in local-dev 12f694b/218cf0d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:49:57 +02:00
mathiasandClaude Opus 5.5 0e2bd3d90e fix(mcp): audit record keeps legacy name/index alias targets
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 15s
CD / Build & Import (push) Successful in 7s
A caller using the #38 aliases (name for repo, index for number) left no
target in the tools/call audit record.

Refs #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.16.1
2026-10-01 20:41:51 +02:00
mathiasandClaude Opus 5.5 b149dccdbe chore(context): re-sync adapters after root brain-scoping correction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:41:47 +02:00
mathiasandClaude Opus 5.5 724a997002 feat(mcp): audit-log every tools/call (OWASP MCP08)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 31s
CD / Build & Import (push) Successful in 14s
tools/call dispatched with no record, so repo_delete, pr_merge and
file_delete left no trail in gitea-mcp; only auth acceptance was logged.

One slog record per call: tool, caller, token fingerprint, target
(owner/repo/number/branch/path/tag), outcome, duration. Every other
argument is reduced to args_bytes so the audit line never copies file
content or issue bodies.

Closes #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.16.0
2026-10-01 20:34:59 +02:00
mathiasandClaude Opus 5 59dabc9455 chore(context): adopt canonical context-sync.sh and re-sync adapters
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 17s
CD / Deploy via GitOps (push) Successful in 5s
infra's scripts/context-sync.sh is the estate canonical, and it moved in infra
262369b (a SIGPIPE fix on the placeholder-reporting path: `printf | grep -o |
head -1` became `head -1 < <(grep -o ...)`). Every other repo still carried the
old line, so ContextSyncScriptDrift and ContextAdapterDrift were both firing.

Also picks up the canonical AGENT.md change from local-dev a01ee80 — rule 5's
GitOps review lane and the new precedence rule — which every repo's adapters
need regenerated for regardless of the script.

Refs mathias/infra#470

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:36:32 +02:00
mathiasandClaude Opus 5 6b57a8ba8a chore(context): regenerate derived adapters from the canonical root context
CD / Lint / Test / Vet (push) Successful in 41s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 10s
ContextAdapterDrift was firing for 13 repos. Picks up behaviour rule 7
("Finish what's closable"), which landed in ~/dev/.context/AGENT.md and never
reached any repo's derived files.

Where mcp.json changes, it also picks up root MCP config the repo was stale on:
brain/gitea/infra move from public hostnames to tailnet Serve URLs, and gitea's
bearer changes from GITEA_MCP_TOKEN to DMABE_GITEA_API_TOKEN — gitea-mcp
authenticates by pass-through and 401s on the former, so a harness syncing from
the old file had a dead gitea server and no signal saying so.

No literal secrets: mcp.json carries ${VAR} placeholders only.

CLAUDE.md deliberately unchanged — context-sync writes it from PROJECT.md alone,
since Claude Code walks up to ~/dev/CLAUDE.md for root context.

Refs mathias/infra ContextAdapterDrift

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 07:34:24 +02:00
mathiasandClaude Opus 5 e1e9a2e30e feat(auth): every accepted request names the path that granted it
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 16s
CD / Build & Import (push) Successful in 16s
Closes #60. Closes #58 (fixed in mcp-chassis v0.7.0, consumed here).

Three auth paths reach this server and none of them said so on success.

PassthroughMiddleware logged nothing at all, success or failure. While verifying
#59 end to end there was no way to confirm from the logs whether calls were
using the new PAT pass-through — the caller's own Gitea PAT, the caller's own
identity — or falling through to the shared JWT/static path. The only available
reasoning was inference from which failures were absent.

    INFO mcp auth accepted auth_path=passthrough caller=alice token_fp=...
    INFO mcp auth accepted auth_path=jwt        caller=mathiasbq token_fp=...
    INFO mcp auth accepted auth_path=static     token_fp=...

The first line matters more than the other two. Pass-through is the path where a
request acts as a SPECIFIC Gitea user; an audit trail that cannot say which
identity performed a write is not an audit trail.

chassisauth.Fingerprint, not a local equivalent. The chassis emits token_fp on
its own accepted and rejected lines, and two different fingerprints for the same
token cannot be correlated — which is the only reason the field exists. Exported
from the chassis in v0.7.1 for exactly this.

A token that is not a valid PAT still falls through SILENTLY. The chassis owns
the verdict, and logging here as well would double-count every static-token
request, since all of them reach this middleware first. Asserted, because "logs
nothing on the failure path" is indistinguishable from "was never called".

Also lands #58 by consuming mcp-chassis v0.7.0: a JWT presented to a server with
no JWT auth configured now reports jwt_auth_not_configured rather than
static_token_mismatch. That log line is what sent a debugging session into
re-running the OAuth flow while the credentials were valid the whole time.

Verified: task check EXIT=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.15.0
2026-09-14 20:59:04 +02:00
mathiasandClaude Opus 5 09bde6df8a fix(health): /healthz answers now, and /readyz takes a degraded pod out
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 30s
CD / Build & Import (push) Successful in 15s
Closes #57. Consumes mcp-chassis v0.6.0 (mcp-chassis#4).

Two halves of one six-day outage. The chassis fix alone would not have fixed
this one.

/HEALTHZ WAS A CONSTANT

newHealthzHandler marshalled its body ONCE at startup and served those bytes for
the process lifetime. On 2026-09-14 it reported jwt.status=degraded for six days
after Authentik had recovered, because the answer was computed before Authentik
was up and could never change. A health endpoint that cannot change its answer
is not a health endpoint.

It now evaluates readiness per request. That is also what drives recovery: the
chassis resolves a pending issuer on demand, so every probe is a retry.

/READYZ IS NEW, AND IT IS THE ACTUAL CONTROL

Through all three occurrences the pod was 1/1 Running and readiness-green while
401ing every OAuth caller. k8s had no signal and kept routing to it. /readyz
answers 503 while JWT auth is degraded, so the pod leaves the Service instead of
serving guaranteed failures, and the kubelet's probe cadence becomes the retry
loop — no background goroutine, no context to own.

/healthz deliberately stays 200. The process is alive; a liveness probe must not
restart it, because restarting does not make the IdP come back sooner.

A server with no issuer configured is always ready. Making readiness depend on an
issuer it was never given would pull every static-token-only MCP out of its
Service — asserted, not assumed.

THE RETRY-WITHOUT-THE-K8S-ISSUER FALLBACK IS GONE

It existed because one unreachable issuer used to fail the whole constructor and
take Authentik auth down with it. Since v0.6.0 each issuer resolves lazily and
independently, so a down issuer degrades only itself. The warning it logged —
"multi-issuer init failed; retrying without the k8s issuer" — was the first line
of today's incident; it no longer has a reason to exist.

A constructor error now means a config error, which exits non-zero rather than
warning and continuing.

TESTS

healthz_live_test.go, written first and red (newReadyzHandler undefined, and
newHealthzHandler's signature could not express a state that changes). The
sharpest assertion is that readiness is evaluated TWICE for two requests — a
handler that caches cannot pass it.

The existing table test is ported, not weakened: same three cases, same expected
statuses, only the mechanism by which state reaches the handler changed.

Verified: task check EXIT=0 (lint, vet, race). The lint failure it surfaced was
pre-existing and is fixed in the preceding commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.14.0
2026-09-14 20:06:15 +02:00
mathiasandClaude Opus 5 aaaa4b4d64 fix(lint): reflect.Ptr -> reflect.Pointer, unblocking task check
`task check` has been red at HEAD since 2bb1fef adopted the canonical
.golangci.yml from infra/ci-templates, which enables govet's `inline` analyzer.
Nothing to do with the change it blocks; reproduced against a clean HEAD before
touching it.

reflect.Ptr is the pre-Go-1.18 spelling of reflect.Pointer and is a deprecated
alias for it. Identical constant, no behaviour change.

Separate commit because it is unrelated to the auth work that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:06:00 +02:00
mathias 2bb1fef543 chore(lint): adopt the canonical .golangci.yml from infra/ci-templates
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 5s
CD / Deploy via GitOps (push) Successful in 4s
Canonical lives at infra ci-templates/golangci.yml, distributed by the
infra#309 pattern: byte-identical copies, checksum drift gate in infra. Do
not edit this file here -- edit the canonical and run `task ci:sync`.

Verified locally before pushing, not pushed-and-hoped: golangci-lint v2.11.4
against a Go 1.26.1 toolchain (matching CI) reports 0 issues here.

Also applies gofumpt, which this config enables and the golangci-lint v2
default set does not check at all. That gap is why the drift was invisible:
earlier passes ran gofmt, which is weaker. Mechanical, deterministic,
build+vet verified.
2026-09-05 14:42:59 +02:00
mathias ee9ac99c40 style: apply gofmt
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 4s
CD / Deploy via GitOps (push) Successful in 4s
Estate-wide consolidation pass. golangci-lint v2 default set enables NO
formatter, so gofmt drift accumulated silently across the estate -- 8 repos
were carrying it, this one included. Same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofmt -w`, nothing hand-edited. Verified
before commit -- gofmt -l now reports 0, go build and go vet both pass.

Clears this repo to adopt the canonical .golangci.yml (infra
ci-templates/golangci.yml, infra#309 pattern) in a later wave.
2026-09-05 12:40:34 +02:00
mathias ef7ccc8295 fix(lint): check resp.Body.Close()'s error (errcheck)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 6s
Caught by CI, not locally -- this sandbox's golangci-lint crashes on any
repo, a pre-existing environment issue unrelated to this code (documented
in dispatch's own session history). gitea-mcp's actual CI run has a
working linter and correctly flagged this.
v0.13.0
2026-09-05 11:27:22 +02:00
mathias 4d6597639e feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
Gitea's own token-creation and admin-user endpoints reject token auth
outright -- POST /users/{u}/tokens returns 'auth required', POST
/admin/users returns 'token does not have at least one of required
scope(s), required=[write:admin]' -- for ANY existing token, by design. An
existing token cannot mint another token or create an account; the only
way through either endpoint is real basic auth as an actual site admin.
That is exactly what a sandboxed agent session cannot safely hold.

internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable
against a fake transport (5 tests, no network). Given real admin
username/password, it creates the target account (or resets its password
if it already exists -- deliberately unconditional, gated behind a
required ConfirmServiceAccount flag since that reset is only safe for a
dedicated bot account), mints it a scoped token by authenticating AS that
account (Gitea requires this specifically -- an admin cannot mint on
another account's behalf), grants it read-only collaborator access on
named repos, and stores the result via a SecretStore -- never returning
the token or either password to the caller.

internal/localtools: the MCP tool wrapper (mint_service_account_token) and
the real op-CLI-backed SecretStore (token passed via stdin, never argv,
per the estate's own secret-handling rule). Deliberately its OWN package,
not internal/tools -- this capability is far too dangerous to risk it ever
being wired into RegisterAll and reachable from the shared HTTP gitea-mcp
server, even behind OIDC.

internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line,
standard MCP stdio framing) for the existing registry.Tool interface --
the only transport gitea-mcp had before this was HTTP (server.go), which a
sandboxed session can reach but a real admin password never should be
piped through.

cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code
session (.mcp.json stdio server) or run by hand, via 'op run --env-file
~/.op-env' so admin credentials arrive just-in-time and never touch a
sandboxed session.

11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the
shared HTTP server) changed.
2026-09-05 11:25:41 +02:00
mathias 419ae19cdb fix(context): sync to the estate canonical context-sync.sh, drop Cursor/Aider
CD / Lint / Test / Vet (push) Successful in 24s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 5s
This repo's copy of context-sync.sh was two days stale against the version
Mathias's Cursor/Aider decision (2026-09-01) already landed in dma-cli:
still generated .cursorrules and .aider.conventions.md, and lacked the
--force/placeholder pre-flight and .context/mcp.json root-merge dma-cli's
copy has had since.

Found while building the estate-wide context-sync drift gate (ADR-0019) --
this is exactly the drift the gate exists to catch, found by hand while
establishing the baseline it will check going forward.

- script synced to the canonical hash (dma-cli's current copy)
- .cursorrules, .aider.conventions.md, .aider.conf.yml deleted
- Taskfile's drift assertion and file list narrowed to match
- AGENTS.md, .context/system-prompt.txt regenerated against canonical

task check -> exit 0.
2026-09-05 08:01:07 +02:00
mathiasandClaude Opus 5 e4d07c106c ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 9s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:23 +02:00
mathiasandClaude Opus 5 1d75bf9b25 fix(ci): no expression syntax in a run: block's comments
CD / Lint / Test / Vet (push) Successful in 10s
CD / Deploy via GitOps (push) Successful in 4s
CD / Build & Import (push) Successful in 5s
act interpolates a `run:` block INCLUDING its shell comments, so prose
containing Actions expression syntax is evaluated as an expression. A
comment I added yesterday explaining why a fallback was computed in shell
contained that syntax as an example; act tried to evaluate it, failed
with "Unavailable context: a", and the step then wrote NO outputs at all.

In gitea-mcp that emptied `needs.build.outputs.image-tag`, so its deploy
job committed `image: localhost:5000/gitea-mcp:` to the infra repo --
which is not valid YAML, so Flux's apps Kustomization went Ready:False
and NOTHING in k3s/apps reconciled estate-wide until infra 51ceb7c.

The build still went green throughout, because the shared build-image.sh
falls back to `git rev-parse --short HEAD` when SHA_TAG is empty. A
robust default hid a broken input -- worth remembering as a pattern.

Same comment text removed here; the explanation stays, the syntax does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 15:29:40 +02:00
mathiasandClaude Opus 5 417ab60707 ci: call the shared build-image script instead of an inline copy (infra#309)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Failing after 1m2s
Wave 2. The buildctl/skopeo step here was one of nineteen near-identical
copies; the implementation now lives in .gitea/build-image.sh,
byte-identical to mathias/infra's ci-templates/build-image.sh and held
there by a drift gate. Labels, tags and build-args are inputs.

Behaviour unchanged: same registry, same :sha/:latest/version tags, same
revision and source labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 12:35:52 +02:00
mathiasandClaude Opus 5 f6446c2515 ci: drop the k3s ctr smoke test, impossible on the containerized runner
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 6s
The step ran `sudo k3s ctr images pull` / `ctr run` against the host's
containerd. That worked when the runner was the Arch host; since it was
containerized (infra#233) the image has no sudo, no ctr and no containerd
socket, so the step fails with `sudo: command not found`. It only
surfaced now because the buildah wall (infra#241) killed the job before
this step ever ran.

The deploy job's `Verify rollout` and `Confirm pod running new image`
assert the same property against the real workload rather than a 5s
throwaway container, which is why tapir dropped its smoke test under the
same issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:54:55 +02:00
mathiasandClaude Opus 5 50c447f673 ci(cd): build with buildctl instead of rootless buildah (infra#241)
CD / Build & Import (push) Failing after 14s
CD / Deploy via GitOps (push) Skipped
CD / Lint / Test / Vet (push) Successful in 27s
Rootless buildah in the containerized gitea-runner pod cannot remap
UID/GID on a fresh base-image pull and has no OCI runtime, so this
Dockerfile's `RUN apk add` / `RUN go build` cannot execute there at all.
The last green run here (2026-07-22) predates the runner's
containerization (infra#233, 2026-07-29), so the build has been latently
broken since.

buildctl hands the build to koala's host buildkitd over the socket the
pod already mounts; skopeo pushes the resulting OCI archive once per tag,
since buildctl has no `buildah push` equivalent. The VERSION build-arg
carries over as `--opt build-arg:VERSION=`, so the ldflags version stamp
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:49:47 +02:00
mathiasandClaude Sonnet 5 43714047be fix(auth): owner allowlist trusts pass-through-authenticated callers (#59)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 25s
CD / Deploy via GitOps (push) Has been skipped
Allowlist.Check now takes ctx: when the caller authenticated with
their own Gitea PAT (pass-through, v0.12.0), it skips the static
GITEA_MCP_ALLOWED_OWNERS check entirely — Gitea's own permission
model already gates that caller's access more precisely than a coarse
owner-name list can. The static list still applies unchanged for the
shared static-token/JWT path, where it's the only defense against the
service token's blast radius.

Mechanical: every tool call site already had ctx in scope, so this is
a signature-only change at 41 call sites, no other tool behavior
changes. Closes the "Deferred" item from #59.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v0.12.1
2026-07-22 08:31:43 +02:00
mathiasandClaude Sonnet 5 5601927dc8 feat(auth): per-caller Gitea PAT pass-through (#59)
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 26s
CD / Deploy via GitOps (push) Has been skipped
Replaces the shared GITEA_MCP_DEFAULT_TOKEN for all callers. When a
request's bearer validates directly against Gitea's own /api/v1/user,
that token is used for every upstream call this request makes instead
of the service PAT, and the caller identity comes from Gitea's own
login rather than the proxy header. Any other bearer (static token,
JWT, none) falls through unchanged to the existing chassis auth.

Prep: Authentik now SSOs into Gitea (infra a32801c), so each real user
can mint their own PAT from their own linked account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v0.12.0
2026-07-22 08:22:50 +02:00
mathiasandClaude Opus 4.8 a16c5b0537 fix(code_search): replace the fantasy REST endpoint with a real client-side grep
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
code_search was calling GET /api/v1/repos/{owner}/{repo}/search?type=code — an
endpoint that does not exist. Confirmed against a live Gitea 1.25.5 instance's
swagger spec: only /repos/search, /repos/issues/search, /topics/search etc are
real. Gitea's own web UI code search falls back to server-side `git grep`
because no Repository Indexer is enabled here, and that fallback is an
HTML-only route, not JSON API — so no REST endpoint exists to call, working or
not. Every call to code_search 404'd on the real server.

This went undetected because the existing tests mocked the fantasy endpoint
directly (asserting the request path was .../search?type=code and handing back
a canned JSON envelope) — a textbook case of tests validating a fake instead of
the real system, giving false confidence the tool worked.

SearchCode now does the search itself: resolves the default branch, walks the
tree (GetTree), and substring-matches q (case-insensitive, literal — not a
regex, to keep behavior predictable and avoid a ReDoS surface from
user-supplied input) against fetched file contents (GetFileContents) — the same
approach Gitea's own indexer-less fallback uses, just client-side. Bounded by:
- codeSearchMaxFiles (2000) — files scanned per call
- codeSearchMaxFileSize (512KB, checked via the tree listing's own Size field,
  before any fetch) — skip large blobs
- a binary-extension denylist checked before fetch, plus a null-byte content
  check after fetch, for extensions the denylist misses

Pagination is over the full sorted result set, recomputed each call (no
server-side index to page through incrementally) — acceptable for the repo
sizes this targets; documented as a known limitation, not a hidden footgun.

The tool layer (internal/tools/code_search.go) is UNCHANGED — SearchCode's
signature and the []CodeSearchHit contract are identical, so this is fully
isolated to the client layer + its tests.

Tests: match found in tree, case-insensitive matching, binary extension
skipped WITHOUT fetching (asserted via the fake's fetch log, not just absent
from results), oversized file skipped without fetching, null-byte content
skipped after fetching, pagination across a full sorted set, empty-query
validation — plus the tool-layer single-repo and fan-out tests rewritten
against the same real endpoints (GetRepo/GetTree/GetFileContents +
ListRepos), replacing their fantasy-endpoint fakes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.11.0
2026-07-07 11:29:05 +02:00
mathiasandClaude Opus 4.8 1eceb5f7fe ci(cd): skip docs-only pushes; move HOW_GITEA_MCP_WORKS into docs/
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
Relocate the doc under docs/ and add paths-ignore: ['docs/**'] to the CD push
trigger so documentation-only changes no longer rebuild the image and roll the
pod. Branch pushes with only docs/ changes are skipped; tag (v*) pushes and any
push touching code still deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:43:46 +02:00
mathiasandClaude Opus 4.8 09a7fad6ba feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
Extends the existing dispatch_allow flag (which already injects .dispatch-allow,
#43/#51/#53) to also append owner/name to mathias/dispatch's git-tracked
dispatch-repos.txt (dispatch#19) — the second of the two required dispatch
gates. Being listed there is necessary but not sufficient on its own (the repo
still needs its own .dispatch-allow marker) — both are applied independently,
neither implies the other, matching dispatch#3's structural trust-zone design
where both must say yes.

Idempotent: a repo already listed (e.g. dispatch_allow re-requested on resume)
is a no-op, not a duplicate line. Failure is reported in its own
dispatch_allowlist_failure field, distinct from partial_failure (substitution)
and dispatch_allow_failure (the marker file) — the three gates can each fail
independently, never conflated (same principle as #51).

The allowlist owner/repo/path/branch are hardcoded to match mathias/dispatch's
own DISPATCH_ALLOWLIST_* env defaults, confirmed unoverridden in the live
CronJob (2026-07-06) before implementing.

Tests: fresh registration (existing entries preserved, not clobbered),
already-listed no-op (zero writes), allowlist-write failure as a distinct
field, dispatch_allow=false never touches the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.10.0
2026-07-07 00:33:39 +02:00
mathiasandClaude Opus 4.8 02af7ee71c docs: add HOW_GITEA_MCP_WORKS — architecture, auth, tools, deploy
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Successful in 4s
Explains the request lifecycle (Origin → Bearer → Caller → MCP), multi-issuer
auth (static bearer / Authentik / k8s SA tokens per ADR-0011), the owner
allowlist + caller-attribution footer, the single-service-PAT upstream client,
the ~60 tools + input-hygiene layer, config, health, and the CI/CD + netpol-pilot
deployment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:29:14 +02:00
mathiasandClaude Opus 4.8 240c3ec081 feat(auth): trust the k8s cluster OIDC issuer for ServiceAccount tokens (ADR-0011 D1)
CD / Lint / Test / Vet (push) Successful in 17s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 3s
Additive: gitea-mcp now validates JWTs from a LIST of issuers — the existing
Authentik issuer (DEX_ISSUER_URL) AND, when K8S_ISSUER_URL is set, the in-cluster
k8s OIDC issuer for audience-bound ServiceAccount tokens. Lets in-cluster pods
authenticate with kubelet-rotated projected SA tokens instead of a static bearer.

- config: K8S_ISSUER_URL + K8S_MCP_AUDIENCE.
- cmd/gitea-mcp/k8soidc.go: HTTP client that fetches the k8s OIDC discovery/JWKS
  with the cluster CA + this pod's SA bearer (k3s requires an authed fetch;
  anonymous is 401).
- main.go: build the issuer list; switch NewJWTValidator -> NewMultiJWTValidator.
  The k8s issuer is best-effort — if its client can't be built (not in a pod) or
  its discovery is unreachable at startup, it is DROPPED and we fall back so
  Authentik/static auth is never taken down. Smoke-tested: off-pod it logs the
  skip and starts static-only; static-bearer /mcp returns 400 (auth passed), not 401.
- bump mcp-chassis v0.3.0 -> v0.5.0 (multi-issuer + per-issuer HTTPClient).

Refs infra ADR-0011; enables retiring the in-cluster static bearer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:16:39 +02:00
mathiasandClaude Opus 4.8 51b823ae79 fix(create_project): idempotent rename write completes a stray write+delete split (#53)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
The rename path (write new path, then delete old path) is two separate,
non-atomic API calls. If a prior partial run's write succeeded but the paired
delete failed — plausible under the same infra#179 flakiness resume (#50)
exists to work around — a resume would recompute the identical rename and
blind-create (no sha) at a path that already exists, hitting a conflict and
getting stuck needing another resume cycle just to re-report the same thing.

renameEntry now reads the new path first: if it already holds the correct
content (prior write succeeded), the write is skipped and only the
outstanding delete of the old path runs; if the new path exists but differs,
it's updated with the fetched sha instead of blind-created; if the old path
is already gone by delete time, that's treated as done, not a failure.
Mirrors injectDispatchAllow's (#51) read-before-write idempotency pattern.

Test: TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly — a stray
old path plus an already-correct new path resolves to a single delete, zero
redundant writes, no partial_failure. All prior create_project tests
unaffected (backward compatible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.2
2026-07-04 14:09:26 +02:00
mathiasandClaude Opus 4.8 ac337955e7 fix(issue_label): schema wrongly required labels, blocking label_ids-only callers (#52 review finding)
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
Independent adversarial review of #52 (v0.8.0) caught a schema/implementation
mismatch: the advertised InputSchema marked "labels" as required, but Call
already treated labels/label_ids as either-or. An MCP client that validates
arguments against the advertised schema before dispatch would reject a
label_ids-only call as invalid even though the code was written to serve it —
and that path had zero test coverage either way.

Dropped "labels" from the required array (owner/repo/number remain required);
runtime validation already correctly requires at least one of labels/label_ids.
Added TestIssueLabelAppliesByIDOnly (asserts ListLabels is never called when
IDs are already known) and a schema-lock test for the fixed contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.1
2026-07-04 13:57:05 +02:00
mathiasandClaude Opus 4.8 b288462a1c feat(create_project): resume=true makes substitution durable against infra#179 (#50, #51)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
parallax#1 was the real-world evidence: infra#179's branch-writability stall
(>40s observed) blew past the tool's 5s budget, substitution ran zero files,
and the repo shipped genuinely broken (go.mod still read `module
__MODULE_PATH__`) — recoverable only via a manual clone/sed/git-mv/push.

Adds `resume: bool`. When true, skips template lookup and repo generation
entirely — the destination must already exist — and jumps straight to the
tree-walk substitution. This is safe to re-invoke repeatedly because
substituteEntry already compares against the branch's CURRENT state on every
call (GetTree is re-fetched fresh each time): a file already fixed in a prior
partial pass shows up already-correct or already-renamed and is a no-op. So the
actual blocker to resumability was purely the "destination must NOT exist"
guard on the create path — flipped it for resume, no change needed to the
substitution logic itself.

Consequences of resume existing:
- infra179FinalizeMessage (#46) now points at the concrete recovery — "call
  this tool again with resume=true" — instead of manual clone/sed guidance.
- "no placeholders substituted" only loud-fails on a FRESH create; on resume,
  finding nothing left to do is the expected steady state (success).
- dispatch_allow injection (#43) is now idempotent (read-before-write, update
  with sha if present-but-different, skip if already correct) so a resumed
  call with dispatch_allow=true doesn't error re-creating a path that already
  exists (#51's root cause).
- #51's other ask: dispatch_allow failures now land in their own
  dispatch_allow_failure field, never conflated with substitution's
  partial_failure — the two can independently succeed/fail.

Tests: resume with no destination (error, names "nothing to resume"), resume
continuing a partial substitution (asserts /generate is never re-called, only
the still-wrong file is rewritten), resume when already fully done (success,
not the fresh-create loud-fail), dispatch_allow idempotent re-injection
(two-phase test capturing the real written content, no test-visible knowledge
of the internal constant), and dispatch_allow_failure as a distinct field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.0
2026-07-04 13:49:38 +02:00
mathiasandClaude Opus 4.8 82823aad1e feat(labels): add label_list + issue_label tools (#52)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
Closes #52 — unblocks parallax#3 dispatch labeling, which needs to both
discover a repo's label set and attach labels to an issue by name (the
CAD pipeline doesn't track Gitea's internal numeric label IDs).

- gitea.Client: ListLabels, AddIssueLabels (additive POST, matches
  Gitea's own semantics — no delete-then-post needed); extend the
  existing Label struct with Color for label_list's output.
- tools.LabelList (read-only, allowlisted): lists a repo's labels.
- tools.IssueLabel (allowlisted): resolves label names to IDs via
  ListLabels, so callers pass names (the primary interface) instead of
  hunting for numeric IDs; also accepts label_ids for callers that
  already have them. An unknown name fails closed, naming exactly which
  label wasn't found.
- Bump TestRegisteredToolCount 39 -> 41 in the same commit (this
  project was bitten today by a locked count going stale silently).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.8.0
2026-07-04 13:44:14 +02:00
mathiasandClaude Opus 4.8 64176fe6d7 fix(repo_mirror_push): resolve mirror credential from server env, not the payload (#49)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
The mirror credential no longer has to ride the tool-call payload (which is
persisted to transcript → claudewatcher → brain → gitea history). Adds
remote_password_env: the name of a server-side env var the tool resolves at call
time, so the secret stays in the server process. An env name that resolves to
empty errors loudly rather than silently sending an empty password. Raw
remote_password still works but the schema/description now mark it DISCOURAGED.

Tests: password resolved from the env var (never in output); unset env var →
ErrValidation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.7.1
2026-07-04 09:00:07 +02:00
mathiasandClaude Opus 4.8 6d344c74a8 feat(tbd_ship): idempotent re-invoke — resume existing branch/PR (#48)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
A second tbd_ship for the same change no longer errors on "branch exists":
CreateBranch conflict is tolerated, and if a PR is already open for the head,
CreatePullRequest's conflict/validation error resolves it via ListPullRequests
(matching head.ref). Identical file content on the branch skips the write, so a
resume produces no redundant empty-diff commit. Then the same CI gate runs and
merges if now green — so "poll or re-invoke" (the #40 UX) actually works.

Test: TestTBDShip_Resume_ExistingBranchAndPR (branch+PR exist, content
unchanged → no write, merges when green). First-call paths unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.7.0
2026-07-04 08:30:30 +02:00
mathiasandClaude Opus 4.8 a515f53731 build(version): inject real build version via ldflags (#47)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
Dockerfile takes ARG VERSION (default "dev") and stamps it into
main.version with -X. CD passes --build-arg VERSION=<git tag on v* builds,
else the short sha>, so the running pod logs the actual build instead of
"dev". Verified locally: `-ldflags -X main.version=...` embeds the string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.6.1
2026-07-04 08:28:05 +02:00
mathiasandClaude Opus 4.8 f0527c94bc fix(test): bump TestRegisteredToolCount to 39 for tbd_ship (#40)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
The registered-tool-count lock still expected 38; tbd_ship makes 39. This is
why the v0.6.0 CD check job went red (build+deploy skipped, pod stayed on
v0.5.2). Count updated; task check green (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:49:26 +02:00
mathiasandClaude Opus 4.8 c40a9d9003 test(tbd_ship): cover review-protected + merge-conflict fail-closed paths (#40)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Adds Call-level tests for the two remaining no-merge paths (green CI but the
base requires review, and green CI but the merge returns 409), completing the
acceptance matrix alongside the green/pending/red/no-CI cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:41:12 +02:00
mathiasandClaude Opus 4.8 8ebad95adf feat(tools): add tbd_ship — CI-gated trunk-based ship (#40)
CD / Build & Import (push) Has been skipped
CD / Lint / Test / Vet (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
An intent verb for the trunk-based loop: branch from base → write the file →
open a PR → auto-merge (squash) → delete the branch. One call instead of
orchestrating file_write_branch + pr_create + workflow_run_status + pr_merge +
branch_delete and remembering the conventions each time.

The load-bearing safety is a pure, fail-closed CI gate (evaluateShipGate):
merge=true ONLY when every workflow run for the PR head commit is
completed+success AND the base branch is not review-protected. Every other
state — CI pending / red / absent, review-required base, or an unclean merge —
fails closed to PR-only and returns the PR with a reason. A change with no CI
gate is never auto-merged to trunk (cf. agentsquad#36: non-compiling code
reviewer-approved straight to main with no CI wall).

- ci_timeout_seconds polls the head commit's runs to completion (default 0 =
  snapshot, returns pending right after opening the PR).
- Derives a deterministic short-lived branch (tbd/<slug>-<hash>); handles new
  and existing files (fetches the blob sha for updates).
- Adds head.sha + mergeable to the PR struct.
- Tests: full gate matrix (green/pending/red/cancelled/none/mixed/protected) +
  Call happy-merge, no-CI fail-closed, red fail-closed, allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.6.0
2026-07-04 00:40:02 +02:00
mathiasandClaude Opus 4.8 169040c073 chore(context): re-sync adapters from root AGENT.md (skills → mathias/skills repo)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:39:35 +02:00
mathiasandClaude Opus 4.8 834a994af9 chore(housekeeping): canonical git.d-ma.be host + honest version string
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
- Dockerfile: GOPRIVATE and the http→https insteadOf rewrite now name
  git.d-ma.be (was the pre-rename gitea.d-ma.be; masked at build time only by
  GOPROXY=direct + GOSUMDB=off).
- .context/PROJECT.md Repo URL → git.d-ma.be, adapters regenerated
  (CLAUDE.md, AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt).
- main.go: version is now a `-ldflags -X main.version` overridable var defaulting
  to "dev" instead of a hardcoded, drifting "0.1.0".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.5.2
2026-07-03 23:58:34 +02:00
mathiasandClaude Opus 4.8 0a12e905c9 fix(create_project): drop defunct hyperguild new-project from finalize guidance (#46)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
The infra#179 partial_failure message and the tool descriptor told callers to
"Finalize locally with `hyperguild new-project`" — but that command does not
exist (the hyperguild CLI only has tier/brain/mode; it was specced, never built).
It pointed users at a dead end.

Extracted the message into a pure infra179FinalizeMessage() and reworded it to
name the actual remaining work — cloning the repo and substituting the leftover
__PROJECT_NAME__ / __MODULE_PATH__ placeholders, or retrying — with no reference
to any scaffolding CLI. Descriptor updated to match. Unit-tested the wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.5.1
2026-07-03 23:45:25 +02:00
mathiasandClaude Opus 4.8 72ba89be63 feat(auth): adopt chassis v0.3.0 — auth audit logs + 503 on Dex outage (#6, #9)
CD / Build & Import (push) Successful in 22s
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
Bumps mcp-chassis to v0.3.0, which adds structured audit logging on every auth
rejection and returns 503 temporarily_unavailable (not a silent 401) when Dex is
unreachable at validation time. Wires slog.SetDefault so those audit lines flow
through gitea-mcp's JSON handler.

Together with the earlier /healthz jwt-status reporting and startup degradation
warning, this closes #6 (Dex-down is now observable and distinct from a bad
token) and #9 (auth failures are audit-logged: reason, IP, token type, hashed
fingerprint — never the raw token).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.5.0
2026-07-03 23:17:39 +02:00
mathiasandClaude Opus 4.8 9b7f53bdda feat(auth): document caller header precedence + warn on conflict (#10)
CallerMiddleware silently preferred X-Auth-Request-User over X-Forwarded-User
with no explanation and no signal when both were set. Documented the precedence
(X-Auth-Request-User is the verified OIDC identity oauth2-proxy sets, so it is
authoritative; X-Forwarded-User is a fallback), and it now takes a *slog.Logger
and warns when both headers are present and disagree, so a proxy
misconfiguration is visible instead of silently resolved. Table-driven tests
cover precedence (both/single/none) and the conflict-warning path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:57:24 +02:00
mathiasandClaude Opus 4.8 4c1f36f9eb chore(rename): module gitea.d-ma.be → git.d-ma.be/mathias/gitea-mcp (#39)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 23s
Gitea host renamed (infra ADR-0004); the mcp-chassis dep already migrated
(3329ff3), so the sequencing gate is clear. `go mod edit -module` + bulk import
rewrite across all .go files. gitea-mcp is a server binary (not an imported
library), so no downstream consumers break. build + task check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.4.2
2026-07-03 22:48:26 +02:00
mathiasandClaude Opus 4.8 99586984bf docs(file_write_branch): clarify direct-to-main + PR-flow in descriptor (#35)
The tool already commits directly to any existing branch (BranchExists→upsert,
no create — covered by TestFileWriteBranchSkipsCreateWhenBranchExists), so
`branch:"main"` is a one-call direct-to-main write. The descriptor said "feature
branch", understating it. pr_create/pr_merge/repo_list already exist, closing the
other two #35 gaps (PR loop + owner repo listing). Descriptor now states both
paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:47:37 +02:00
mathiasandClaude Opus 4.8 e0bede0547 fix(tools): reject empty repo/name identifier at tool layer (#37)
An empty required repo identifier built a trailing-empty path segment
(`/api/v1/repos/{owner}/`) and leaked gitea's bare 404. parseArgs now
validates, via reflection, that `repo`/`name` string args are non-empty and
returns a typed ErrValidation naming the field. Optional identifiers (e.g.
code_search's owner-wide fan-out `repo`) opt out with `,omitempty`. `owner` is
already enforced by the allowlist check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:47:05 +02:00