Compare commits

...
8 Commits
Author SHA1 Message Date
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>
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>
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>
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>
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>
2026-07-04 14:09:26 +02:00
59 changed files with 1230 additions and 199 deletions
+4
View File
@@ -4,6 +4,10 @@ name: CD
push:
branches: [main]
tags: ["v*"]
# Docs-only pushes don't change the image — skip the build/deploy roll.
# (Only applies to branch pushes; tag pushes always trigger.)
paths-ignore:
- 'docs/**'
env:
IMAGE: gitea-mcp
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"strings"
"time"
)
const (
saCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
)
// k8sBearerRT adds this pod's ServiceAccount bearer to every request. k3s serves
// its OIDC discovery/JWKS over the cluster CA and requires an AUTHENTICATED
// request (anonymous is 401), so the JWKS fetch must carry a token — ADR-0011.
type k8sBearerRT struct {
token string
base http.RoundTripper
}
func (b k8sBearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+b.token)
return b.base.RoundTrip(r)
}
// k8sOIDCClient builds an HTTP client that can reach the in-cluster k8s OIDC
// discovery + JWKS: it trusts the cluster CA and carries this pod's SA bearer.
// Returns an error (not running in a pod, files unreadable) so the caller can
// skip the k8s issuer without disabling other auth.
func k8sOIDCClient() (*http.Client, error) {
ca, err := os.ReadFile(saCAFile)
if err != nil {
return nil, fmt.Errorf("read cluster CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("parse cluster CA: no certs in %s", saCAFile)
}
tok, err := os.ReadFile(saTokenFile)
if err != nil {
return nil, fmt.Errorf("read SA token: %w", err)
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: k8sBearerRT{
token: strings.TrimSpace(string(tok)),
base: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
},
}, nil
}
+34 -3
View File
@@ -37,7 +37,36 @@ func main() {
ctx := context.Background()
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
// can't be built (not in a pod) or its discovery is unreachable at startup,
// it is dropped so existing Authentik/static auth is never taken down.
var issuers []chassisauth.IssuerConfig
if cfg.DexIssuerURL != "" {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
}
if cfg.K8sIssuerURL != "" {
if client, cerr := k8sOIDCClient(); cerr != nil {
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
} else {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
}
}
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
// startup. Don't let it take down Authentik JWT auth — retry without it.
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
for _, ic := range issuers {
if ic.IssuerURL != cfg.K8sIssuerURL {
pub = append(pub, ic)
}
}
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
}
if jwtInitErr != nil {
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
}
@@ -63,8 +92,10 @@ func main() {
mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
auth.PassthroughMiddleware(giteaClient, mcpSrv,
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
),
),
))
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
+200
View File
@@ -0,0 +1,200 @@
# How gitea-mcp works
`gitea-mcp` is a custom **MCP (Model Context Protocol) front door for Gitea** — a
small Go HTTP service that exposes ~60 Gitea operations as MCP tools over
Streamable HTTP, so a claude.ai connector (or any MCP client) can drive the
homelab's Gitea (`https://git.d-ma.be`) with proper auth, an owner allowlist,
caller attribution, and defensive input handling.
It is **not** a generic Gitea proxy. It is an opinionated, allowlisted, single-
tenant front door built on the shared **`mcp-chassis`** auth library.
---
## 1. Request lifecycle
Everything is wired in `cmd/gitea-mcp/main.go` on a plain `http.ServeMux`:
```
POST /mcp
→ OriginAllowlist (internal/mcp/origin.go) browser Origin gate
→ BearerMiddleware (mcp-chassis auth) authN: static token OR JWT
→ CallerMiddleware (internal/auth/caller.go) extract caller identity
→ MCP server (internal/mcp/server.go) JSON-RPC dispatch → tools
GET /healthz unauthenticated health JSON
GET /.well-known/oauth-protected-resource RFC 9728 metadata (when Dex set)
```
The middleware order is deliberate: Origin first (cheap reject), then authN,
then identity extraction, then the MCP handler. A tool call is a JSON-RPC
`tools/call` that the registry dispatches to a `Tool` handler.
---
## 2. Authentication (`mcp-chassis` `BearerMiddleware`)
`Authorization: Bearer <token>` is checked with this precedence (`auth/bearer.go`
in `mcp-chassis`):
1. **Static bearer** — constant-time compare against `GITEA_MCP_STATIC_TOKEN`.
Wins immediately, never emits a `WWW-Authenticate` challenge. This is the
service-to-service path (repo `.mcp.json` files carrying `GITEA_MCP_TOKEN`).
2. **JWT validation** — against a **list of trusted OIDC issuers**
(`NewMultiJWTValidator`, ADR-0011):
- **Authentik** (`DEX_ISSUER_URL` + `MCP_AUDIENCE=claude-ai`) — the claude.ai
web connector after its OAuth handshake.
- **k3s cluster OIDC** (`K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp`) — lets
in-cluster pods authenticate with audience-bound projected **ServiceAccount
tokens** instead of a static bearer. The k8s issuer is additive and
best-effort: if the in-cluster OIDC can't be reached at startup it is
dropped so Authentik auth is never taken down (see `cmd/gitea-mcp/main.go`
and `cmd/gitea-mcp/k8soidc.go`).
3. Otherwise **401**. If `MCP_RESOURCE_URL` + `DEX_ISSUER_URL` are set, the 401
carries a `WWW-Authenticate: … resource_metadata=…` header (RFC 9728) so
claude.ai's OAuth discovery can find `/.well-known/oauth-protected-resource`.
A JWKS/issuer outage yields **503** (`ErrUnavailable`), distinct from a
present-but-invalid token's **401**, so a transient IdP blip is retried rather
than treated as a hard auth failure.
### The k8s SA-token wrinkle (ADR-0011)
k3s serves its OIDC discovery/JWKS over the **cluster CA** and requires an
**authenticated** request (anonymous → 401). So `k8sOIDCClient()` builds an HTTP
client that trusts `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` and
carries the pod's own SA bearer, passed to the chassis via
`IssuerConfig.HTTPClient`. Proven live: a real `aud=gitea-mcp` SA token → the
running server → HTTP 200.
### Origin allowlist
`OriginAllowlist` (`internal/mcp/origin.go`) rejects any request whose `Origin`
header is not in `GITEA_MCP_ORIGIN_ALLOWLIST` (`https://claude.ai`,
`https://api.anthropic.com`). An **empty** Origin (server-side callers) is
allowed — Origin is a browser-only header.
---
## 3. Authorization, identity & attribution
- **Owner allowlist** (`internal/allowlist`) — tools only operate on owners in
`GITEA_MCP_ALLOWED_OWNERS` (default `mathias`). A call for any other owner is
rejected before it reaches Gitea.
- **Caller identity** (`internal/auth/caller.go`) — the authenticated username is
read from reverse-proxy identity headers, `X-Auth-Request-User` (the verified
OIDC identity, authoritative) preferred over `X-Forwarded-User`. Conflicts are
logged, not silently resolved.
- **Identity footer** (`internal/identity/footer.go`) — mutating tools that write
a body (issue/PR comments, creates) append
`_Created via git-mcp on behalf of @<caller>_`, so actions taken through the
front door are attributable even though all upstream calls use one service PAT.
---
## 4. Upstream Gitea access
`internal/gitea/Client` is a thin REST client over `GITEA_BASE_URL`
(`https://git.d-ma.be`). Every upstream call carries a **single service PAT**
`GITEA_MCP_DEFAULT_TOKEN` — as `Authorization: token <PAT>` (`client.go`). So
gitea-mcp is a *front door*, not a credential pass-through: the caller's identity
is captured for attribution, but Gitea sees one service account. A 60s branch
cache and a 30s HTTP timeout round it out.
Defensive guard: paths with an empty owner/repo segment (`//`) are rejected
locally (`hasEmptySegment`) so callers get a typed validation error instead of
Gitea's opaque `/api/swagger` 404.
---
## 5. Tools
Tools are registered in `internal/tools/registry.go` into a `registry.Registry`;
each implements the `Tool` interface (name, JSON schema, handler). ~60 tools,
by area:
| Area | Tools |
|------|-------|
| Repos | list, get, search, status, create, delete, update, tree, topics_update, mirror_push |
| Files | file_read, file_write_branch, file_delete, dir_list |
| Branches | branch_list, branch_delete, branch_protection_get |
| Issues | list, get, create, edit, close, reopen, comment, label, list_comments |
| PRs | list, get, create, comment, merge, files_diff |
| Labels / Releases / Tags | label_list, release_create, tag_create |
| Workflows (Actions) | run_list, run_status, run_trigger |
| Search / Templates | code_search, create_project_from_template |
| Composite | tbd_ship (trunk-based ship helper) |
Shared tool-layer hygiene (`internal/tools/tool.go`):
- **Alias normalization** — `repo``name` and `number``index` are reconciled so
a tool works whichever spelling a caller sends (an explicit canonical wins).
- **Required-identifier validation** — an empty `repo`/`name` is rejected at the
tool layer (avoids the bare-404 leak); `owner` is covered by the allowlist.
- **Page-size cap** — limits are clamped to 50.
---
## 6. Configuration (env)
| Var | Purpose | Example |
|-----|---------|---------|
| `GITEA_MCP_PORT` | listen port | `8080` |
| `GITEA_BASE_URL` | upstream Gitea | `https://git.d-ma.be` |
| `GITEA_MCP_DEFAULT_TOKEN` | **upstream** service PAT (all Gitea calls) | *(secret)* |
| `GITEA_MCP_STATIC_TOKEN` | static bearer for service-to-service auth | *(secret)* |
| `GITEA_MCP_ALLOWED_OWNERS` | owner allowlist | `mathias` |
| `GITEA_MCP_ORIGIN_ALLOWLIST` | permitted browser Origins | `https://claude.ai,…` |
| `DEX_ISSUER_URL` / `MCP_AUDIENCE` | Authentik issuer + audience | `…/o/claude-ai/`, `claude-ai` |
| `K8S_ISSUER_URL` / `K8S_MCP_AUDIENCE` | k8s OIDC issuer + audience (SA tokens) | `https://kubernetes.default.svc.cluster.local`, `gitea-mcp` |
| `MCP_RESOURCE_URL` | this server's public URL for `.well-known` metadata | `https://git-mcp.d-ma.be` |
Secrets come from k8s Secrets (`gitea-mcp-secrets`, `gitea-mcp-static-token`);
non-secret values are inlined in the Deployment.
---
## 7. Health & observability
- `GET /healthz``{"ok":true,"jwt":{"status":"disabled|enabled|degraded","last_error":"…"}}`.
`degraded` = a JWT issuer was configured but init failed (static-token auth
still works) — distinguishes "IdP unreachable" from "JWT not configured".
- Every auth rejection is audit-logged (`mcp-chassis` `deny`) with the reason,
client IP, token *type*, and a truncated SHA-256 **fingerprint** — never the
raw token, so nothing secret lands in logs.
---
## 8. Deployment & CI/CD
- **Runs in** the `gitea-mcp` k3s namespace (`git.d-ma.be/mathias/infra`
`k3s/apps/gitea-mcp/`): a Deployment on `localhost:5000/gitea-mcp:<sha>`, an
ExternalSecret for tokens, and a **default-deny NetworkPolicy** + an
`allow-ingress-nginx` rule (this namespace is the P6.1 netpol pilot — the only
ingress allowed is from `ingress-nginx`).
- **Public endpoint** `https://git-mcp.d-ma.be` (NPM → ingress-nginx → svc:8080).
- **Pipeline** (`.gitea/workflows/cd.yml`, self-hosted koala runner): quality gate
(test/vet) → `buildah` image → `localhost:5000` → smoke test → the deploy job
clones infra over SSH, `sed`s the image tag in `k3s/apps/gitea-mcp/deployment.yaml`,
commits, and triggers a **Flux** reconcile. Push to `main` ⇒ new image ⇒ rolled.
---
## 9. Operational notes
- **Rollback** = revert the image tag (or the env change) in the infra Deployment;
Flux rolls back. Auth changes are additive, so enabling/disabling the k8s issuer
never affects the Authentik/static paths.
- **NetworkPolicy kill switch** — delete the `gitea-mcp` NetworkPolicies to
restore open ingress if the pilot ever locks something out.
- **claude.ai connector flakiness** — the claude.ai→gitea-mcp MCP path
intermittently hits a Cloudflare "you have been blocked" page (the error names
`anthropic.com`). On the Claude Code CLI, prefer the direct Gitea REST API for
reliability; the MCP connector remains the claude.ai-web path. (brain:
`prefer-rest-api-skills-over-mcp-on-cli-cloudflare-waf`.)
## References
- Auth library: `git.d-ma.be/mathias/mcp-chassis` (`auth` package) — shared
`BearerMiddleware` + multi-issuer `JWTValidator` + RFC 9728 handler.
- Multi-issuer / SA-token design: infra `docs/decisions/ADR-0011-service-to-service-auth.md`.
- Source map: `cmd/gitea-mcp/{main,healthz,k8soidc}.go`,
`internal/{mcp,auth,gitea,tools,allowlist,identity,registry,config}`.
+1 -1
View File
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
go 1.26.2
require (
git.d-ma.be/mathias/mcp-chassis v0.3.0
git.d-ma.be/mathias/mcp-chassis v0.5.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/stretchr/testify v1.11.1
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0=
git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
+14 -2
View File
@@ -1,6 +1,11 @@
package allowlist
import "fmt"
import (
"context"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
type Allowlist struct {
owners map[string]struct{}
@@ -14,10 +19,17 @@ func New(owners []string) *Allowlist {
return &Allowlist{owners: m}
}
func (a *Allowlist) Check(owner string) error {
// Check gates owner access to the static list — except for a caller
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
// access Gitea's own permission model already gates more precisely than a
// coarse owner name list ever could.
func (a *Allowlist) Check(ctx context.Context, owner string) error {
if owner == "" {
return fmt.Errorf("owner required")
}
if _, ok := gitea.TokenFromContext(ctx); ok {
return nil
}
if _, ok := a.owners[owner]; !ok {
return fmt.Errorf("owner %q not in allowlist", owner)
}
+24 -4
View File
@@ -1,16 +1,36 @@
package allowlist_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
func TestAllowlistCheck(t *testing.T) {
a := allowlist.New([]string{"mathias", "acme"})
assert.NoError(t, a.Check("mathias"))
assert.NoError(t, a.Check("acme"))
assert.Error(t, a.Check("evil"))
assert.Error(t, a.Check(""))
ctx := context.Background()
assert.NoError(t, a.Check(ctx, "mathias"))
assert.NoError(t, a.Check(ctx, "acme"))
assert.Error(t, a.Check(ctx, "evil"))
assert.Error(t, a.Check(ctx, ""))
}
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
// is gated by Gitea's own permission model, not the MCP's static owner list —
// otherwise a legitimate second user could never touch their own repos.
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.NoError(t, a.Check(ctx, "someone-else"))
}
// Empty owner is a structural input error, not an authz question — still
// rejected even on the pass-through path.
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.Error(t, a.Check(ctx, ""))
}
+5 -2
View File
@@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
"x_forwarded_user", fwdUser)
}
ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
})
}
func withCaller(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, ctxKey{}, user)
}
func Caller(ctx context.Context) string {
if v, ok := ctx.Value(ctxKey{}).(string); ok {
return v
+36
View File
@@ -0,0 +1,36 @@
package auth
import (
"context"
"net/http"
"strings"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// TokenValidator asks the upstream service who a bearer token belongs to.
type TokenValidator interface {
ValidateToken(ctx context.Context, token string) (username string, ok bool)
}
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
// if the request's bearer token validates directly against Gitea, it's used
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
// the server's shared default token. Any other bearer (static token, JWT, or
// none) falls through to fallback unchanged — this only adds a capability, it
// never removes the existing auth paths.
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
if hasBearer && token != "" {
if login, ok := validator.ValidateToken(r.Context(), token); ok {
ctx := withCaller(r.Context(), login)
ctx = gitea.WithToken(ctx, token)
onValid.ServeHTTP(w, r.WithContext(ctx))
return
}
}
fallback.ServeHTTP(w, r)
})
}
+82
View File
@@ -0,0 +1,82 @@
package auth_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
type fakeValidator struct {
login string
ok bool
calls int
}
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
f.calls++
return f.login, f.ok
}
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
validator := &fakeValidator{login: "alice", ok: true}
var gotCaller string
var gotToken string
var gotOK bool
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotCaller = auth.Caller(r.Context())
gotToken, gotOK = gitea.TokenFromContext(r.Context())
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("fallback should not be called for a valid PAT")
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer alices-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, "alice", gotCaller)
assert.True(t, gotOK)
assert.Equal(t, "alices-pat", gotToken)
}
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
validator := &fakeValidator{ok: false}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called for an invalid token")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
}
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
validator := &fakeValidator{ok: true, login: "alice"}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called with no Authorization header")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
}
+4
View File
@@ -15,6 +15,8 @@ type Config struct {
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
K8sIssuerURL string // K8S_ISSUER_URL, in-cluster OIDC issuer for ServiceAccount-token auth (ADR-0011 D1); empty disables
K8sAudience string // K8S_MCP_AUDIENCE, required audience claim for k8s SA tokens
}
func Load() (Config, error) {
@@ -28,6 +30,8 @@ func Load() (Config, error) {
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
MCPAudience: os.Getenv("MCP_AUDIENCE"),
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
K8sIssuerURL: os.Getenv("K8S_ISSUER_URL"),
K8sAudience: os.Getenv("K8S_MCP_AUDIENCE"),
}
return cfg, nil
}
+39
View File
@@ -3,6 +3,7 @@ package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -19,6 +20,21 @@ type Client struct {
branchCache *expirable.LRU[string, string]
}
type ctxTokenKey struct{}
// WithToken overrides the token used for upstream Gitea calls made with the
// returned context, taking precedence over the Client's configured default
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
func WithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, ctxTokenKey{}, token)
}
// TokenFromContext returns the token set by WithToken, if any.
func TokenFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxTokenKey{}).(string)
return v, ok
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
@@ -28,6 +44,23 @@ func NewClient(baseURL, token string) *Client {
}
}
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
// using that token, not the client's configured default token) and returns
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
if err != nil || status != http.StatusOK {
return "", false
}
var user struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
return "", false
}
return user.Login, true
}
// DefaultBranch returns the default branch for a repo. Cached for 60s.
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
key := owner + "/" + name
@@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
return nil, 0, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
@@ -135,6 +171,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
return nil, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
+43
View File
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
}
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
ctx := gitea.WithToken(context.Background(), "caller-token")
_, status, err := c.GetJSON(ctx, "/api/v1/user")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Equal(t, "token caller-token", gotAuth)
}
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "candidate-token")
assert.True(t, ok)
assert.Equal(t, "alice", login)
}
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "bad-token")
assert.False(t, ok)
assert.Empty(t, login)
}
func TestRetryOnPostNotRetried(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+135 -15
View File
@@ -1,10 +1,13 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"encoding/base64"
"fmt"
"net/url"
"path"
"sort"
"strings"
)
type CodeSearchHit struct {
@@ -14,30 +17,147 @@ type CodeSearchHit struct {
Score float64 `json:"score,omitempty"`
}
type codeSearchEnvelope struct {
Data []CodeSearchHit `json:"data"`
OK bool `json:"ok"`
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
// and scan, so one call against a huge repo can't run indefinitely.
const codeSearchMaxFiles = 2000
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
// before any fetch) — almost certainly binary/vendor/generated content, not
// worth the cost of fetching just to reject.
const codeSearchMaxFileSize = 512 * 1024
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
// obviously-binary content by extension, checked against the tree listing.
var codeSearchBinaryExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
".pyc": true, ".o": true, ".a": true,
}
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
// has no code-content-search endpoint — confirmed against a live 1.25.5
// instance's swagger spec (only /repos/search, /repos/issues/search,
// /topics/search etc exist). The web UI's OWN code search falls back to
// server-side `git grep` because no Repository Indexer is enabled on that
// instance, and that fallback is an HTML-only route, not JSON API. So this
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
// user-supplied input) — against file contents.
//
// Pagination is over the FULL sorted result set, recomputed on every call —
// there is no server-side index to page through incrementally, so requesting
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
// fix, and is an infra decision, not something gitea-mcp controls.
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
if q == "" {
return nil, fmt.Errorf("q is required: %w", ErrValidation)
}
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
owner, repo, url.QueryEscape(q), page, limit)
body, status, err := c.GetJSON(ctx, path)
r, err := c.GetRepo(ctx, owner, repo)
if err != nil {
return nil, err
return nil, fmt.Errorf("resolve repo: %w", err)
}
if err := MapStatus(status, body); err != nil {
return nil, err
branch := r.DefaultBranch
if branch == "" {
branch = "main"
}
var env codeSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
tree, err := c.GetTree(ctx, owner, repo, branch, true)
if err != nil {
return nil, fmt.Errorf("tree walk: %w", err)
}
return env.Data, nil
qLower := strings.ToLower(q)
all := make([]CodeSearchHit, 0)
scanned := 0
for _, e := range tree.Tree {
if ctx.Err() != nil {
break
}
if e.Type != "blob" {
continue
}
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
continue
}
if e.Size > codeSearchMaxFileSize {
continue
}
if scanned >= codeSearchMaxFiles {
break
}
scanned++
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
if ferr != nil {
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
}
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
if derr != nil {
continue
}
if bytes.IndexByte(decoded, 0) >= 0 {
continue // binary content the extension filter missed
}
content := string(decoded)
contentLower := strings.ToLower(content)
count := strings.Count(contentLower, qLower)
if count == 0 {
continue
}
idx := strings.Index(contentLower, qLower)
all = append(all, CodeSearchHit{
Path: e.Path,
Snippet: codeSearchSnippet(content, idx, len(q)),
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
Score: float64(count),
})
}
sort.Slice(all, func(i, j int) bool {
if all[i].Score != all[j].Score {
return all[i].Score > all[j].Score
}
return all[i].Path < all[j].Path
})
start := (page - 1) * limit
if start >= len(all) {
return []CodeSearchHit{}, nil
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
// codeSearchSnippet returns a short window of text centered on a match,
// trimmed to a single line-ish window so results read like a grep hit rather
// than a content dump.
func codeSearchSnippet(content string, idx, matchLen int) string {
const window = 60
start := idx - window
if start < 0 {
start = 0
}
end := idx + matchLen + window
if end > len(content) {
end = len(content)
}
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
return strings.TrimSpace(snippet)
}
+150 -17
View File
@@ -2,8 +2,12 @@ package gitea_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -11,22 +15,65 @@ import (
"github.com/stretchr/testify/require"
)
func TestSearchCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
// UI's own code search falls back to server-side `git grep`, an HTML-only
// route, not JSON API). Records which paths were actually fetched, so tests
// can assert a file was SKIPPED (binary ext, oversized) without ever being
// read, not just absent from results.
type codeSearchFake struct {
files map[string]string // path -> content
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
fetched []string
}
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/code_search.go",
"snippet":"func (c *Client) SearchCode",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
"score":2.5
}],
"ok":true
}`))
}))
p := r.URL.Path
switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
var entries []string
for path, content := range f.files {
size := int64(len(content))
if s, ok := f.sizes[path]; ok {
size = s
}
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
f.fetched = append(f.fetched, path)
content, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
}
func TestSearchCode_FindsMatchInTree(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
@@ -34,6 +81,92 @@ func TestSearchCode(t *testing.T) {
require.NoError(t, err)
require.Len(t, hits, 1)
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
assert.Contains(t, hits[0].Snippet, "SearchCode")
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
assert.Equal(t, 1.0, hits[0].Score)
}
func TestSearchCode_CaseInsensitive(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"README.md": "# MyProject\n\nBuild instructions here.\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
require.NoError(t, err)
require.Len(t, hits, 1)
}
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"assets/logo.png": "SearchCode", // would match if fetched — must not be
"main.go": "package main\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
}
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
f := &codeSearchFake{
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
}
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"data.bin": "SearchCode\x00binary-marker",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
}
func TestSearchCode_Pagination(t *testing.T) {
files := map[string]string{}
for i := 0; i < 5; i++ {
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
}
f := &codeSearchFake{files: files}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
require.NoError(t, err)
require.Len(t, page1, 2)
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
require.NoError(t, err)
require.Len(t, page2, 2)
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
}
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
c := gitea.NewClient("http://unused", "tok")
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
+108 -75
View File
@@ -2,8 +2,10 @@ package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -16,22 +18,76 @@ import (
"github.com/stretchr/testify/require"
)
func TestCodeSearchSingleRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
// for a set of repos — the real endpoints code_search's underlying SearchCode
// now uses (Gitea's REST API has no code-content-search endpoint; see
// internal/gitea/code_search.go's doc comment).
type multiRepoSearchFake struct {
owner string
repos []string // ListRepos response, in this order
files map[string]map[string]string // repo -> path -> content
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
}
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/repos.go",
"snippet":"func (c *Client) ListRepos",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
"score":3.0
}],
"ok":true
}`))
}))
p := r.URL.Path
for _, repo := range f.repos {
base := "/api/v1/repos/" + f.owner + "/" + repo
switch {
case p == base && f.fail[repo]:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
return
case p == base:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
return
case strings.HasPrefix(p, base+"/git/trees/"):
var entries []string
for path := range f.files[repo] {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
return
case strings.HasPrefix(p, base+"/contents/"):
path := strings.TrimPrefix(p, base+"/contents/")
content, ok := f.files[repo][path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
enc := base64.StdEncoding.EncodeToString([]byte(content))
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
return
}
}
if p == "/api/v1/users/"+f.owner+"/repos" {
var entries []string
for _, repo := range f.repos {
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
}
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
return
}
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
func TestCodeSearchSingleRepo(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra"},
files: map[string]map[string]string{
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
}
func TestCodeSearchAllowlistRejects(t *testing.T) {
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
}
func TestCodeSearchFanOutHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
case "/api/v1/repos/mathias/gitea-mcp/search":
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "gitea-mcp"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
}
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/broken/search":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "broken"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
},
fail: map[string]bool{"broken": true},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -134,9 +175,11 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
require.NoError(t, err)
var result struct {
Results []struct{ Repo string `json:"repo"` } `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
Results []struct {
Repo string `json:"repo"`
} `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.True(t, result.Partial)
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
}
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/alpha/search":
// low score
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/beta/search":
// high score
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"alpha", "beta"},
files: map[string]map[string]string{
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
},
}
srv := httptest.NewServer(f.handler(t))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 2)
// First result must be the high-score one
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
assert.True(t, result.Results[0].Score > result.Results[1].Score,
"expected results sorted by score desc, got %v then %v",
result.Results[0].Score, result.Results[1].Score)
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
}
+116 -32
View File
@@ -57,7 +57,7 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"description":{"type":"string"},
"private":{"type":"boolean"},
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the project is opt-in for headless dispatch (dispatch#3). Default false. Safe to re-request on resume."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file (dispatch#3) AND register owner/name into mathias/dispatch's git-tracked allowlist (dispatch-repos.txt, dispatch#19) — both gates are required for the watcher to actually pick the repo up; each is applied independently and idempotently. Default false. Safe to re-request on resume."},
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
},
"required":["owner","name"]
@@ -82,15 +82,28 @@ const dispatchAllowContent = "# Presence of this file marks this repo as opt-in
"# See dispatch#3.\n"
type createProjectResult struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// The dispatch allowlist (dispatch#19) is a fixed integration point — a
// specific file in a specific repo the mathias/dispatch watcher reads at the
// start of every cycle. Hardcoded to match its own DISPATCH_ALLOWLIST_OWNER/
// _REPO/_PATH defaults (unconfigured in the live CronJob, confirmed 2026-07-06).
const (
dispatchAllowlistOwner = "mathias"
dispatchAllowlistRepo = "dispatch"
dispatchAllowlistPath = "dispatch-repos.txt"
dispatchAllowlistBranch = "main"
)
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args createProjectArgs
if err := parseArgs(raw, &args); err != nil {
@@ -98,7 +111,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
}
// Allowlist check first.
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
@@ -147,19 +160,26 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
}
}
// Opt the new project into headless dispatch if asked: presence of a
// .dispatch-allow file on the default branch marks it dispatch-eligible
// (dispatch#3). Skip if substitution itself already stalled — don't mark an
// incomplete repo dispatch-eligible. A failure here is reported in its OWN
// field (gitea-mcp#51) — it must never be indistinguishable from a
// substitution failure, since one can succeed while the other doesn't.
// Opt the new project into headless dispatch if asked. Two INDEPENDENT gates,
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
// allowlist. Skip both if substitution itself already stalled — don't mark an
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
// (gitea-mcp#51/#54) — never conflated with each other or with substitution,
// since any one of the three can fail independently of the other two.
if args.DispatchAllow && result.PartialFailure == "" {
didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch)
if fail != "" {
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
result.DispatchAllowFailure = fail
} else if didWrite {
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
}
ownerName := args.Owner + "/" + args.Name
if didRegister, fail := t.registerDispatchAllowlist(ctx, ownerName); fail != "" {
result.DispatchAllowlistFailure = fail
} else if didRegister {
result.DispatchAllowlisted = true
}
}
// If substitution stalled because the generated branch wasn't writable in time,
@@ -302,6 +322,42 @@ func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, own
return true, ""
}
// registerDispatchAllowlist appends ownerName ("owner/name") to
// mathias/dispatch's git-tracked dispatch-repos.txt if not already listed
// (gitea-mcp#54) — idempotent, safe to call on every resume. This is the
// SECOND of the two required dispatch gates: being listed here is necessary
// but not sufficient on its own (the repo also needs its own .dispatch-allow
// marker, injectDispatchAllow's job) — dispatch#3's structural trust-zone
// design requires both independently. Returns whether a write actually
// happened, so the caller only reports a fresh registration, not a no-op.
func (t *CreateProjectFromTemplate) registerDispatchAllowlist(ctx context.Context, ownerName string) (didRegister bool, failure string) {
fc, err := t.c.GetFileContents(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, dispatchAllowlistBranch)
if err != nil {
return false, fmt.Sprintf("read %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return false, fmt.Sprintf("decode %s: %v", dispatchAllowlistPath, err)
}
content := string(decoded)
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == ownerName {
return false, "" // already listed — idempotent no-op
}
}
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
Branch: dispatchAllowlistBranch,
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
Sha: fc.Sha,
}); err != nil {
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
return true, ""
}
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
// async template-generate (infra#179) leaves the branch unwritable within the
// budget. It points at the concrete recovery step — re-invoking this same tool
@@ -374,21 +430,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
if renamed {
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
}); err != nil {
return "", fmt.Sprintf("write %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
Branch: branch,
Sha: fc.Sha,
Message: fmt.Sprintf("template: drop placeholder path %s", path),
}); err != nil {
return "", fmt.Sprintf("delete %s: %v", path, err)
}
return path + " -> " + newPath, ""
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
@@ -401,3 +443,45 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
}
return path, ""
}
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
// are separate, non-atomic API calls) completes cleanly instead of erroring on
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
// already holds the correct content, the write is skipped and only the
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
// is a no-op too.
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
switch {
case err == nil:
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
if derr == nil && string(decoded) == newContent {
break // already correct from a prior partial run — skip the write
}
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc, Sha: existing.Sha,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
case errors.Is(err, gitea.ErrNotFound):
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
default:
return "", fmt.Sprintf("read %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
Branch: branch,
Sha: oldSha,
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
}
return oldPath + " -> " + newPath, ""
}
@@ -38,6 +38,12 @@ type fakeTemplateServer struct {
deletes []string
putBodies map[string]string // path -> decoded written content
repoGetsPost int // GET dest after generate (branch fallback)
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
// "" (default) 404s the read, matching a repo that hasn't seeded the file
// (a distinct error path); tests that care set it explicitly.
dispatchRepos string
dispatchReposPuts int
}
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
@@ -60,7 +66,28 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
switch {
case r.Method == http.MethodGet && p == dispatchReposPath:
if f.dispatchRepos == "" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos))
case r.Method == http.MethodPut && p == dispatchReposPath:
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.dispatchRepos = string(dec)
f.dispatchReposPuts++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
@@ -146,11 +173,13 @@ func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
}
type createOut struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// Happy path: whole-tree substitution, content + path rename, correct module host.
@@ -329,6 +358,29 @@ func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
}
// A resume where a prior partial run's rename write SUCCEEDED but its paired
// delete FAILED (both are separate, non-atomic API calls) must complete
// cleanly: recognize the new path is already correct, skip re-writing it, and
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
files := map[string]string{
// stray: delete never completed in the prior run
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
// already correct: the write half of the same prior rename DID complete
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
}
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
// has the correct content (from an earlier successful injection), re-invoking
// must not attempt another write — and must not error the way a naive
@@ -390,6 +442,86 @@ func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
}
// dispatch_allow now ALSO registers the new repo into mathias/dispatch's
// git-tracked allowlist (dispatch-repos.txt) — the second of the two required
// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54).
func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.True(t, out.DispatchAllowlisted)
assert.Equal(t, 1, f.dispatchReposPuts)
assert.Contains(t, f.dispatchRepos, "mathias/new-svc")
// existing entries preserved, not clobbered
assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox")
assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo")
}
// Registering is idempotent: a repo already listed (e.g. a resume re-running
// with dispatch_allow:true) must not produce a duplicate line or a redundant write.
func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) {
files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted
f := newFakeTemplateServerResumed(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration")
assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write")
}
// A failure registering the allowlist is reported in its OWN field — distinct
// from PartialFailure (substitution) AND DispatchAllowFailure (the marker
// file) — since all three are independent gates that can fail independently.
func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\n"
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution succeeded — must not be conflated")
assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated")
assert.NotEmpty(t, out.DispatchAllowlistFailure)
assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure")
}
// dispatch_allow=false/omitted must never touch the allowlist file at all.
func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Empty(t, out.PartialFailure)
assert.False(t, out.DispatchAllowlisted)
assert.Equal(t, 0, f.dispatchReposPuts)
}
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
func TestCreateProject_NameRegexFailure(t *testing.T) {
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Sha == "" {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.State == "" {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -63,7 +63,7 @@ func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -44,7 +44,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
state := args.State
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -55,7 +55,7 @@ func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.Raw
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
createArgs := gitea.CreateRepoArgs{
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Confirm != args.Repo {
+1 -1
View File
@@ -38,7 +38,7 @@ func (t *RepoGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
r, err := t.c.GetRepo(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 30)
+1 -1
View File
@@ -96,7 +96,7 @@ func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.Ra
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
switch args.Action {
+2 -2
View File
@@ -53,7 +53,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if args.Owner != "" {
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
}
@@ -75,7 +75,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if len(parts) != 2 {
continue
}
if t.a.Check(parts[0]) == nil {
if t.a.Check(ctx, parts[0]) == nil {
filtered = append(filtered, r)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if err := t.c.UpdateTopics(ctx, args.Owner, args.Repo, args.Topics); err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
tree, err := t.c.GetTree(ctx, args.Owner, args.Repo, args.Ref, true)
+1 -1
View File
@@ -60,7 +60,7 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Tag == "" {
+1 -1
View File
@@ -145,7 +145,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Path == "" || args.Content == "" || args.Message == "" {
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 10)
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *WorkflowRunStatus) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.RunID < 1 {
+1 -1
View File
@@ -62,7 +62,7 @@ func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (jso
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Workflow == "" {