Compare commits

...
51 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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
mathiasandClaude Opus 4.8 9f12db3d94 fix(pr_merge): advertise canonical number, demote index to alias (#45)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
pr_merge was the only tool still advertising `index` as its id field while
every other issue/PR tool uses the canonical `number` (#38). Flipped its
schema property + required + struct field/tag `index` -> `number`; the existing
`index`->`number` shim keeps legacy `index` callers working, so no shim change
was needed (no canonical-`index` tool remains). Now the id arg is `number`
uniformly across all per-issue/PR tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:58:05 +02:00
mathiasandClaude Opus 4.8 93c32edbb5 feat(tools): make repo the canonical repo-identifier arg; name now an alias (#38)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
Every caller (claude.ai connector, LLMs primed on gitea/GitHub) sends the repo
identifier as `repo`, but the 33 per-repo identifier tools advertised `name`.
The v0.2.8 shim aliased repo->name so it worked, yet the advertised inputSchema
still said `name` — a misleading contract, with the shim load-bearing.

- Flip all 33 identifier tools: schema property + required + struct field/tag
  from `name` to `repo`. A compliant `repo` caller now matches the struct
  directly; the shim is pure back-compat.
- normalizeAliases is now bidirectional (name<->repo), so legacy `name` callers
  still resolve, and repo_create / create_project_from_template — whose `name`
  means "name of the NEW repo", not an existing-repo id — keep `name` and still
  accept `repo`.
- `number`/`index` left as-is (out of scope; separate pre-existing quirk where
  pr_merge advertises `index` rather than `number`).
- Tests: schema-canonical assertion + flipped alias round-trip (explicit `repo`
  wins over `name`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:42:18 +02:00
mathiasandClaude Opus 4.8 4ebea7d023 fix(workflow_run_trigger): 204 dispatch is success; resolve run via listing (#41)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
Gitea's workflow_dispatch endpoint returns 204 No Content with no Location
header. DispatchWorkflow required that header, so every successful dispatch
errored with "missing Location header" and never yielded a run ID — making
the tool unusable for CAD dispatch.

- DispatchWorkflow now returns error-only; 204 = success, no Location needed.
  Body already carried ref+inputs; kept and covered by test.
- Tool snapshots the newest existing workflow_dispatch run before dispatch,
  then polls ListWorkflowRuns after and returns the newest run with ID above
  that baseline (avoids returning a stale prior run). Falls back to an honest
  "dispatched, run not yet registered" result rather than failing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:12:45 +02:00
mathiasandClaude Opus 4.8 711dc46e5e feat(create_project): add dispatch_allow to inject .dispatch-allow (#43)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
Optional dispatch_allow bool (default false). When true, inject a
.dispatch-allow file at repo root on the resolved default branch after
substitution, marking the new project dispatch-eligible (dispatch#3)
without a manual follow-up commit.

Rides the existing upsertRetry path so injection inherits the infra#179
branch-readiness / partial_failure handling; a stalled injection degrades
exactly like substitution. Reported in files_substituted. false/omitted
is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:57:39 +02:00
mathiasandClaude Opus 4.8 039598855c fix(create_project): fast raw create + honest partial_failure (#42, infra#179)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Successful in 6s
gitea's template-generate is slow-async on this instance (repo not writable for
>40s; infra#179) — no synchronous MCP tool can wait that long, and the 60s retry
made the call hang until the client timed out. Bound the write-readiness retry to
5s (a healthy gitea commits in ~1s and this still catches it), and when the branch
isn't writable in time, return a clear partial_failure: repo created, substitution
deferred, finalize locally with `hyperguild new-project`. Substitution logic is
intact and completes automatically once generate is fast (infra#179). Tool
description updated to describe substitution as best-effort. Refs #42, infra#179.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:38:13 +02:00
mathiasandClaude Opus 4.8 d45ba712ce fix(create_project): make the write the branch-readiness gate (#42)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 4s
CD / Build & Import (push) Successful in 22s
BranchExists returns true before the generated branch is writable, so
waitForBranch didn't help and a 2.5s retry budget was too short (branch became
writable ~30s post-generate under load in live testing). Drop waitForBranch;
let upsertRetry be the gate — retry the write on the transient "branch does not
exist" not-found for up to 60s, early-exit on success. Once the first write
lands the branch is writable and the rest succeed immediately. Refs #42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:17:11 +02:00
mathiasandClaude Opus 4.8 4d658004ae fix(create_project): handle gitea generate-async branch race (#42)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
Live e2e surfaced a race unit tests couldn't (mocks are instant): gitea's
/generate returns and serves reads before the branch ref is writable, so the
first content writes 404 "branch does not exist" for a beat — aborting the
whole substitution pass. Add waitForBranch (poll BranchExists after generate)
+ upsertRetry (retry writes on the transient not-found). Test fake now serves
the branch readiness probe. Refs #42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:11:49 +02:00
mathiasandClaude Opus 4.8 3329ff3088 fix(deps): migrate mcp-chassis to git.d-ma.be path — unblock CD build (#74)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
The image build's `go mod download` failed on the stale
gitea.d-ma.be/mathias/mcp-chassis import (the gitea→git rename; the server no
longer serves a matching go-import meta tag). This is the latent #74 breakage
flagged for gitea-mcp, triggered by the first clean rebuild since the rename
(the #42 push). Point at git.d-ma.be/mathias/mcp-chassis v0.2.0 + go mod tidy.

Unblocks deploying the #42 create_project_from_template fix. gitea-mcp's own
module path stays gitea.d-ma.be (main module, not fetched — separate cleanup).
Refs #74, #42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:06:35 +02:00
mathias 2ebaee8d03 chore: re-sync context adapters from canonical AGENT.md
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
Derived adapters drifted after the root ~/dev/.context/AGENT.md gained the
rule-0 pre-task ritual; task check's context:check gate failed on it
(pre-existing, unrelated to #42). Regenerate via context-sync.sh.
2026-07-01 22:53:15 +02:00
mathias e30951ad72 fix(create_project): use fmt.Fprintf in test fake (lint QF1012)
Follow-up to e3cdd23 — staticcheck QF1012 flagged w.Write([]byte(fmt.Sprintf(...)))
in the rewritten test's fake server. Behaviour unchanged; lint gate green.
2026-07-01 22:53:15 +02:00
mathiasandClaude Opus 4.8 e3cdd23260 fix(create_project): substitute the whole tree, rename cmd dir, resolve branch (#42)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
create_project_from_template returned files_substituted:null and produced a
non-building scaffold. Three root causes, all fixed:

1. Empty branch: /generate omits default_branch, so every SubstituteFile read
   hit an empty ref and 404'd → nothing substituted. Resolve the branch
   explicitly (re-fetch the repo; fall back to "main"). The old unit test hid
   this by mocking default_branch:"main".
2. Incomplete + rename-incapable: substitution ran over a fixed 6-file list
   that missed cmd/__PROJECT_NAME__/main.go and could not rename the
   cmd/__PROJECT_NAME__/ directory. Replace with a recursive tree walk:
   content-substitute every blob, and for any path carrying a placeholder,
   rename it (POST-create new path + delete old).
3. Stale module host: __MODULE_PATH__ used gitea.d-ma.be (the pre-rename host,
   which breaks `go mod download` downstream). Use git.d-ma.be.

Also: fail loud — if nothing was substituted, populate partial_failure instead
of returning silent success (the null that started this).

Tests rewritten to drive the tree-walk flow and assert: cmd/ rename (new path
POST + old path delete), git.d-ma.be module substitution, empty-generate-branch
fallback, and the loud-on-nothing path.

Closes #42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:39:48 +02:00
mathiasandClaude Opus 4.8 184d5a95dd fix(tools,gitea): alias repo/index args and reject empty path segments
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 23s
Per-repo MCP tools 404'd while owner-level tools worked (#36). Root cause was
a parameter-name contract mismatch, not a routing fault: every per-repo tool
declares the repo identifier as 'name' (and issue/PR index as 'number'), but
every caller — the claude.ai connector, LLMs primed on gitea's own API — sends
'repo' and 'index'. The unmatched fields zero-valued the upstream path segment,
producing '/api/v1/repos/{owner}//...', which gitea answers with its generic
api-404 whose body points at /api/swagger. That swagger pointer is gitea boiler-
plate, not a misroute — the MCP dispatch was correct all along.

Two layers of defence:
- parseArgs aliases repo->name and index->number (explicit canonical wins;
  alias key left intact so pr_merge's real 'index' field is unaffected). Kills
  the recurrence by accepting the idiomatic argument names.
- the gitea client rejects any path with an empty segment before the HTTP call,
  returning ErrValidation instead of forwarding a malformed path and surfacing
  gitea's opaque swagger-404. Kills the silent-misleading-error class.

Closes #36

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:16:33 +02:00
mathiasandClaude Opus 4.8 e2594f45f2 refactor(tools): extract RegisterAll shared tool registration
main.go and tests now share one registration list so a tool wired in one
place cannot silently go missing from the other. Adds a dispatch round-trip
test asserting every registered tool resolves and ships a parseable schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:52:29 +02:00
mathias f99a8b38cb fix(ci): quote "on" key so Gitea parses workflow triggers
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped
Bare on: parses as YAML boolean true (Norway problem); Gitea then ignores the triggers and silently skips jobs. Quoting forces the string key.
2026-06-03 08:38:46 +02:00
mathias 8a751741a4 feat: add issue_edit tool (#34)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped
Adds issue_edit, mapping to Gitea's PATCH /repos/{owner}/{repo}/issues/{index},
so an existing issue's title and/or body can be edited through the MCP surface.
Previously the only post-create mutation was issue_comment, which buries
backlinks in the thread instead of the canonical body.

Partial patch via pointer fields (omitempty): omitted fields are left
untouched, an explicit empty string clears a field. Body is sent verbatim —
no identity footer — so repeated edits are idempotent, matching the acceptance
criteria. Registered alongside the other issue tools for tool_search discovery.

Closes #34
2026-06-02 16:11:33 +02:00
mathiasandClaude Opus 4.7 668e8fa28d feat: /healthz reports JWT validator status (refs #6)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 20s
CD / Deploy via GitOps (push) Successful in 4s
/healthz now returns JSON with three-state JWT status: disabled
(DEX_ISSUER_URL unset), enabled (validator initialized), or
degraded (configured but init failed — only static-token auth
currently accepted). last_error surfaces the init failure so ops
can correlate with Dex outage windows.

Partial fix for #6. The cited internal/auth/jwt.go moved out
of this repo in 658f4ba (mcp-chassis migration); per-attempt
logging and 503 + WWW-Authenticate temporarily_unavailable
require chassis-side changes and a coordinated v0.1.1 bump
across all MCP consumers — tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:55:09 +02:00
mathiasandClaude Opus 4.7 3c1ca7d3db feat: add issue_list_comments tool (closes #32)
CD / Lint / Test / Vet (push) Successful in 17s
CD / Build & Import (push) Successful in 19s
CD / Deploy via GitOps (push) Successful in 4s
Lists all comments on an issue or PR via GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments.
Read-only, allowlist-gated. Extended IssueComment struct with user/timestamps populated by the list endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:01:34 +02:00
mathiasandClaude Opus 4.7 8bea0d2f27 chore: remove stray cd.yml.notes file from CI retrigger commit
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 19s
CD / Deploy via GitOps (push) Successful in 4s
The file was an accident in commit 24c3533 — meant as a tmp marker,
should have been removed before commit. Harmless but trash. Removing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:26:35 +02:00
mathiasandClaude Opus 4.7 24c353383f ci: retrigger build after chassis repo made public
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 5s
mcp-chassis was created private on 2026-05-22 then ported here in
commit 658f4ba, which caused CI Build to fail when go mod download
hit the chassis URL and got prompted for credentials. The chassis is
now public (Gitea repo flipped via API). No code change needed; this
empty commit retriggers the build pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:17:54 +02:00
mathiasandClaude Opus 4.7 be85baf410 fix(ci): allow Dockerfile build to fetch internal gitea modules
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
mcp-chassis (added in commit 658f4ba) is hosted at gitea.d-ma.be, and
Gitea returns http:// in its go-import meta tag. Default go module
resolution goes through proxy.golang.org (which can't reach internal
hosts) and falls back to direct git, which gets the http:// URL and
refuses it.

Fix:
- GOPRIVATE=gitea.d-ma.be — skip proxy.golang.org
- GOPROXY=direct — direct git, no proxy attempt
- GOSUMDB=off — bypass sumdb (also doesn't know internal modules)
- git config insteadOf rewrites http:// → https:// for gitea.d-ma.be

Without this, gitea-mcp CI Build & Import failed on the chassis port
(sha=658f4ba). Re-running CI should now succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:12:33 +02:00
mathiasandClaude Opus 4.7 658f4ba84f feat(auth): migrate to gitea.d-ma.be/mathias/mcp-chassis v0.1.0
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Failing after 2s
CD / Deploy via GitOps (push) Has been skipped
First real port of the MCP chassis library — abort-criterion check for
spike S3 of the 2026-05 homelab architecture review.

Changes:
- Drop internal/auth/jwt.go (~79 LOC) — chassis provides JWTValidator
  with identical signature.
- Drop internal/auth/bearer.go (~42 LOC) — chassis BearerMiddleware
  has the same static-or-JWT semantics plus an optional WWW-Authenticate
  resource_metadata challenge (consumed via new resourceMetadataURL arg).
- Drop internal/auth/bearer_test.go — same scenarios are covered in
  the chassis bearer_test.go now.
- main.go: import chassis as `chassisauth`, build resourceMetadataURL
  only when both DexIssuerURL + MCPResourceURL are set, replace the
  inline /.well-known/oauth-protected-resource handler with the chassis
  ProtectedResourceHandler.

internal/auth/caller.go (oauth2-proxy header → context) stays — chassis
out-of-scope.

Net LOC change: -~150 LOC duplicated infra + a 5-LOC import.
go.mod gains gitea.d-ma.be/mathias/mcp-chassis v0.1.0 (jwx/v2 + testify
already transitive, no new top-level deps).

Verifies abort criterion: one PR, one binary's worth of port, task check
green (lint + test + vet + govulncheck clean). Per the S3 spike spec,
this clears the chassis to continue. Next port: hyperguild/ingestion
(brain-mcp), filed as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:25:23 +02:00
mathias 60212fc5d2 feat: issue_list + workflow_run_list tools (#28, #29)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Adds the *_list partners that the existing *_get tools have been
missing. Same pattern as repo_list — owner allowlisted, capLimit
helper for pagination, next_page surfaced when the page is full.

internal/gitea/issues.go:
- ListIssues(owner, repo, args) hitting
  GET /api/v1/repos/{owner}/{repo}/issues with type=issues server-side
  so PRs don't leak in (gitea conflates them on this endpoint).
- ListIssuesArgs struct: State, Labels, Since (ISO 8601), Page, Limit.

internal/gitea/workflows.go:
- ListWorkflowRuns(owner, repo, args) hitting
  GET /api/v1/repos/{owner}/{repo}/actions/runs.
- Expanded WorkflowRun struct with DisplayTitle, Event, HeadSHA,
  HeadBranch, WorkflowID, RunNumber, UpdatedAt, Actor so callers
  can pin runs to a commit / branch without a second lookup.
- ListWorkflowRunsArgs: Branch, HeadSHA, Status, Event, Workflow,
  Page, Limit. Status/Event 'all' treated as no-filter.

internal/tools/issue_list.go:
- Default state=open, default limit=30 (matches repo_list).
- next_page returned only when len(issues) == limit.

internal/tools/workflow_run_list.go:
- Default limit=10 (most common use is 'what just happened',
  not paging).
- Returns runs + total + optional next_page.

Tests: table-driven for both — happy path, empty result, filter
combinations, allowlist rejection. workflow_run_list also asserts
the 'status=all is no-op' behavior (no query param emitted).

Closes #28
Closes #29
2026-05-18 08:06:11 +02:00
mathias dc907fb7e0 feat: issue_close + issue_reopen tools (#30)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Adds two MCP tools that PATCH /api/v1/repos/{owner}/{name}/issues/{number}
with {"state":"closed"} or {"state":"open"}. Both use a shared
SetIssueState helper on the gitea client.

- internal/gitea/issues.go: SetIssueState method using the existing
  PatchJSON + MapStatus + json.Unmarshal pattern from GetIssue.
- internal/tools/issue_close.go: IssueClose tool. owner+name+number
  args. Owner allowlist enforced. Returns the updated issue. Reversible
  via issue_reopen, classified LOW risk.
- internal/tools/issue_reopen.go: mirror of IssueClose with
  state="open". Same risk profile.
- Registered both tools in cmd/gitea-mcp/main.go.
- Tests for both: success (asserts PATCH method, path, body), 404,
  and allowlist rejection — same shape as issue_get_test.go.

Closes #30
2026-05-18 07:51:17 +02:00
mathias c4bd3396c4 chore: re-sync context adapters from updated root AGENT.md 2026-05-18 07:51:17 +02:00
mathiasandClaude Opus 4.7 11f86f5d99 chore: adopt trunk-based development
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 3s
Closes #27.

PROJECT.md
- Git section: TBD as the convention. Commit to main, one logical
  change per commit, `task check` locally before push, CI is the
  quality gate. PRs only for the parallel-agent exception.
- Agent rule 6: rewritten to match.

.gitea/workflows/cd.yml
- Drop the pull_request trigger — vestigial under TBD.
- Drop the `if: github.event_name != 'pull_request'` guard on the
  build job (now always true since pull_request no longer fires).
  Tag pushes still build (no version gating regression).
- Deploy `if` left alone — already correctly limits deploy to
  main pushes, skipping tag-push builds.

.githooks/pre-push (new)
- Runs `task check` before every push. Set up via `task setup:hooks`,
  which sets core.hooksPath to the in-repo .githooks dir.

Taskfile.yml
- New `setup:hooks` task to install the pre-push hook on a fresh
  clone.

README.md
- Quickstart section showing `task setup:hooks` + the TBD policy.

Derived adapters regenerated via `task context:sync` and committed
in the same commit (single-commit invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:44:52 +02:00
mathias f7076c9ac8 docs: mark v0.2 complete, set next-up context for v0.2.5
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 4s
2026-05-17 09:26:47 +02:00
143 changed files with 6029 additions and 1812 deletions
+149 -108
View File
@@ -27,6 +27,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -36,6 +44,34 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -49,6 +85,7 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -58,9 +95,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set`
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
@@ -100,18 +160,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
## Knowledge base — actively use it
When available, agents can query the shared knowledge base:
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
### When to query (treat as a reflex)
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
@@ -157,15 +263,17 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
**Skill trigger table — load before starting, not after getting stuck:**
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
@@ -180,7 +288,7 @@ Key skills:
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -208,9 +316,11 @@ Key skills:
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -248,98 +358,29 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+23 -90
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -37,9 +37,11 @@
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -77,98 +79,29 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+149 -108
View File
@@ -32,6 +32,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -41,6 +49,34 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -54,6 +90,7 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -63,9 +100,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
@@ -105,18 +165,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
## Knowledge base — actively use it
When available, agents can query the shared knowledge base:
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
### When to query (treat as a reflex)
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
@@ -162,15 +268,17 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
**Skill trigger table — load before starting, not after getting stuck:**
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
@@ -185,7 +293,7 @@ Key skills:
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -213,9 +321,11 @@ Key skills:
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -253,100 +363,31 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
---
+149 -108
View File
@@ -30,6 +30,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -39,6 +47,34 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -52,6 +88,7 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -61,9 +98,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
@@ -103,18 +163,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
## Knowledge base — actively use it
When available, agents can query the shared knowledge base:
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
### When to query (treat as a reflex)
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
@@ -160,15 +266,17 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
**Skill trigger table — load before starting, not after getting stuck:**
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
@@ -183,7 +291,7 @@ Key skills:
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -211,9 +319,11 @@ Key skills:
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -251,98 +361,29 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+9 -4
View File
@@ -1,11 +1,13 @@
name: CD
on:
"on":
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# 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
@@ -43,7 +45,6 @@ jobs:
name: Build & Import
needs: check
runs-on: self-hosted
if: github.event_name != 'pull_request'
outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }}
steps:
@@ -63,7 +64,11 @@ jobs:
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
# Stamp the real build version: the git tag on a v* build, else the short sha.
VERSION="${{ steps.meta.outputs.version-tag }}"
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "→ Running task check before push..."
task check
echo "✓ pre-push check passed"
+149 -108
View File
@@ -27,6 +27,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -36,6 +44,34 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -49,6 +85,7 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -58,9 +95,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set`
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
@@ -100,18 +160,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
## Knowledge base — actively use it
When available, agents can query the shared knowledge base:
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
### When to query (treat as a reflex)
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
@@ -157,15 +263,17 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
**Skill trigger table — load before starting, not after getting stuck:**
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
@@ -180,7 +288,7 @@ Key skills:
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -208,9 +316,11 @@ Key skills:
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -248,98 +358,29 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+23 -90
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -37,9 +37,11 @@
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -77,98 +79,29 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Always work on a feature branch and open a PR — never push directly to main
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
## Current state — v0.2.5 (2026-05-17)
### Context
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
create_project_from_template) was implemented and pushed directly to main.
All v0.2 work is complete and deployed. No active sprint.
This sprint fixes three remaining gaps found during code review on 2026-05-14.
These are blockers for `hyperguild new-project`.
### What shipped
### Issues to fix (all three in one PR: `fix/v02-patch`)
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
#### #12 — repo_update: add `archived` and `template` fields
**File:** `internal/gitea/repos.go``UpdateRepoArgs` struct
**File:** `internal/tools/repo_update.go` → input schema + args struct
Current main: `e31fd3f`. CI green. Deployed via Flux.
Add to `UpdateRepoArgs`:
```go
Archived *bool
Template *bool
```
### Next up
Add to tool input schema:
```json
"archived": {
"type": "boolean",
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
},
"template": {
"type": "boolean",
"description": "Toggle template repo flag."
}
```
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
Add confirm-guard for `archived=true` (same pattern as `private=false`):
```go
if args.Archived != nil && *args.Archived {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
}
}
```
New test cases to add in `repo_update_test.go`:
- `TestRepoUpdateTool_Archive` — happy path with confirm
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
#### #24 — create_project_from_template: make template selectable
**File:** `internal/tools/create_project_from_template.go`
Add optional `template_name` param to input schema:
```json
"template_name": {
"type": "string",
"enum": ["template-go-web", "template-go-agent"],
"description": "Template repo to generate from. Defaults to template-go-web.",
"default": "template-go-web"
}
```
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
the tool resolves it internally.
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
#### #25 — pr_files_diff: fix same diff returned for all files
**File:** `internal/tools/pr_files_diff.go`
There is a loop bug where all file entries in the response contain the same diff
(the first file's diff is reused for every subsequent file). Find the loop and
ensure each iteration reads and assigns the correct diff for its own file.
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
a distinct diff.
### Definition of done
- [ ] `task check` passes
- [ ] `repo_update` accepts `archived` and `template` params
- [ ] `archived=true` requires `confirm=<repo name>`
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
- [ ] `pr_files_diff` returns distinct diff per file
- [ ] All new test cases pass
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
### After this sprint
Next: `hyperguild new-project` v1 implementation.
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+15 -1
View File
@@ -1,9 +1,23 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
# Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
# http:// in its go-import meta tag, so rewrite to https here and bypass
# the module proxy + sumdb.
RUN apk add --no-cache git && \
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
ENV GOPRIVATE=git.d-ma.be
ENV GOPROXY=direct
ENV GOSUMDB=off
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
# Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
# "dev" for a plain `docker build` (#47).
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gitea-mcp /gitea-mcp
+11
View File
@@ -2,3 +2,14 @@
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
## Quickstart
```bash
task setup:hooks # installs .githooks/pre-push — runs task check before every push
task check # context sync + lint + test + vet
task build # produces bin/gitea-mcp
```
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
hook enforces the quality gate locally; CI re-runs `task check` on every push.
+7
View File
@@ -47,6 +47,13 @@ tasks:
cmds:
- bash scripts/context-sync.sh
setup:hooks:
desc: Install git hooks (.githooks/pre-push)
cmds:
- git config core.hooksPath .githooks
- chmod +x .githooks/pre-push
- echo "✓ git hooks installed (pre-push runs task check)"
context:sync:claude:
cmds: [bash scripts/context-sync.sh claude]
context:sync:agents:
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"net/http"
)
type healthStatus struct {
OK bool `json:"ok"`
JWT jwtStatus `json:"jwt"`
}
// jwtStatus surfaces the runtime state of the Dex JWT validator so ops
// can distinguish "Dex unreachable at startup" from "JWT auth not
// configured" — both previously degraded silently to static-token-only
// (refs hyperguild/gitea-mcp#6).
type jwtStatus struct {
// Status is one of: "disabled" (DEX_ISSUER_URL not set),
// "enabled" (validator initialized), "degraded" (configured but
// init failed; only static-token auth currently accepted).
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
}
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
if initErr != nil {
status.JWT.LastError = initErr.Error()
}
body, _ := json.Marshal(status)
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
}
func jwtStatusFor(dexConfigured, validatorReady bool) string {
switch {
case !dexConfigured:
return "disabled"
case validatorReady:
return "enabled"
default:
return "degraded"
}
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthzHandler(t *testing.T) {
tests := []struct {
name string
dexConfigured bool
validatorReady bool
initErr error
wantStatus string
wantLastError string
}{
{
name: "disabled when DEX_ISSUER_URL unset",
wantStatus: "disabled",
wantLastError: "",
},
{
name: "enabled when validator initialized",
dexConfigured: true,
validatorReady: true,
wantStatus: "enabled",
wantLastError: "",
},
{
name: "degraded when Dex configured but init failed",
dexConfigured: true,
initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"),
wantStatus: "degraded",
wantLastError: "fetch oidc discovery: dial tcp: connection refused",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var got healthStatus
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.True(t, got.OK)
assert.Equal(t, tc.wantStatus, got.JWT.Status)
assert.Equal(t, tc.wantLastError, got.JWT.LastError)
})
}
}
+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
}
+69 -65
View File
@@ -2,22 +2,32 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/config"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
)
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
// "0.1.0" — it now tells the truth instead of a stale literal).
var version = "dev"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
// the same structured handler as the rest of the server.
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
@@ -27,81 +37,75 @@ func main() {
ctx := context.Background()
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
if err != nil {
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
// 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)
}
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New()
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg,
Sessions: mcp.NewSessionStore(),
})
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
// when both MCPResourceURL and a Dex issuer are wired; empty disables
// the challenge so static-only clients aren't pushed into OAuth discovery.
var resourceMetadataURL string
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
}
mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
auth.CallerMiddleware(mcpSrv),
auth.PassthroughMiddleware(giteaClient, mcpSrv,
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
),
),
))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
payload := map[string]any{
"resource": cfg.MCPResourceURL,
"authorization_servers": []string{},
}
if cfg.DexIssuerURL != "" {
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
}
_ = json.NewEncoder(w).Encode(payload)
})
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
if cfg.DexIssuerURL != "" {
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
}
addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
+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}`.
+3 -2
View File
@@ -1,10 +1,10 @@
module gitea.d-ma.be/mathias/gitea-mcp
module git.d-ma.be/mathias/gitea-mcp
go 1.26.2
require (
git.d-ma.be/mathias/mcp-chassis v0.5.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/stretchr/testify v1.11.1
)
@@ -16,6 +16,7 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
+2
View File
@@ -1,3 +1,5 @@
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)
}
+25 -5
View File
@@ -1,16 +1,36 @@
package allowlist_test
import (
"context"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"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, ""))
}
-42
View File
@@ -1,42 +0,0 @@
package auth
import (
"crypto/subtle"
"net/http"
"strings"
)
// BearerMiddleware authenticates requests via the Authorization header.
//
// A request is allowed when:
//
// 1. The Bearer token is a valid JWT issued by the configured Dex OIDC server, or
// 2. The Bearer token matches staticToken (constant-time compare).
//
// Any other case — including missing or empty Authorization header — returns 401.
//
// The Gitea service PAT is intentionally NOT used to authenticate the caller:
// it is only used by the Gitea client for upstream API calls. Decoupling the
// two prevents the MCP endpoint from being reachable anonymously when a service
// PAT happens to be configured.
func BearerMiddleware(jwtValidator *JWTValidator, staticToken string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !hasBearer || bearer == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if jwtValidator.Validate(r.Context(), bearer) {
next.ServeHTTP(w, r)
return
}
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
next.ServeHTTP(w, r)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
})
}
-92
View File
@@ -1,92 +0,0 @@
package auth_test
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func okHandler(called *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if called != nil {
*called = true
}
w.WriteHeader(http.StatusOK)
})
}
func TestBearerMiddleware_NoAuthHeader(t *testing.T) {
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
defer srv.Close()
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBearerMiddleware_NoAuthHeader_RejectsEvenWhenStaticConfigured(t *testing.T) {
// A configured staticToken must not allow unauthenticated callers through.
srv := httptest.NewServer(auth.BearerMiddleware(nil, "any-static", okHandler(nil)))
defer srv.Close()
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBearerMiddleware_EmptyBearer(t *testing.T) {
srv := httptest.NewServer(auth.BearerMiddleware(nil, "static", okHandler(nil)))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
req.Header.Set("Authorization", "Bearer ")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBearerMiddleware_StaticToken_Valid(t *testing.T) {
const staticToken = "my-static-token"
called := false
srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, okHandler(&called)))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
req.Header.Set("Authorization", "Bearer "+staticToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, called)
}
func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) {
srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", okHandler(nil)))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBearerMiddleware_UnknownBearer_NoStatic_NoJWT(t *testing.T) {
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
req.Header.Set("Authorization", "Bearer random-unknown-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
+31 -5
View File
@@ -2,22 +2,48 @@ package auth
import (
"context"
"log/slog"
"net/http"
)
type ctxKey struct{}
func CallerMiddleware(next http.Handler) http.Handler {
// CallerMiddleware extracts the authenticated username from the reverse-proxy
// identity headers and stashes it in the request context for Caller().
//
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
// convention some setups populate instead; it is used only as a fallback when
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
// verified X-Auth-Request-User still wins and we log a warning so the
// misconfiguration is visible rather than silently resolved (#10).
//
// logger may be nil, in which case the conflict warning is skipped.
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("X-Auth-Request-User")
authUser := r.Header.Get("X-Auth-Request-User")
fwdUser := r.Header.Get("X-Forwarded-User")
user := authUser
if user == "" {
user = r.Header.Get("X-Forwarded-User")
user = fwdUser
}
ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
"x_auth_request_user", authUser,
"x_forwarded_user", fwdUser)
}
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
+65 -11
View File
@@ -1,26 +1,80 @@
package auth_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"github.com/stretchr/testify/assert"
)
func TestCallerFromContext(t *testing.T) {
called := false
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
called = true
assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
}))
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
}
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
// absent.
func TestCallerHeaderPrecedence(t *testing.T) {
tests := []struct {
name string
authReq string
forwarded string
wantCaller string
}{
{"auth-request only", "mathiasbq", "", "mathiasbq"},
{"forwarded fallback", "", "fwduser", "fwduser"},
{"both present, same", "same", "same", "same"},
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
{"neither", "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var got string
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = auth.Caller(r.Context())
}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.authReq != "" {
req.Header.Set("X-Auth-Request-User", tc.authReq)
}
if tc.forwarded != "" {
req.Header.Set("X-Forwarded-User", tc.forwarded)
}
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, tc.wantCaller, got)
})
}
}
// When both headers are present and disagree, a warning is logged so the proxy
// misconfiguration is visible rather than silent.
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Auth-Request-User", "mathiasbq")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
assert.True(t, called)
req.Header.Set("X-Auth-Request-User", "authuser")
req.Header.Set("X-Forwarded-User", "fwduser")
h.ServeHTTP(httptest.NewRecorder(), req)
logged := buf.String()
assert.Contains(t, logged, "conflicting")
assert.Contains(t, logged, "authuser")
assert.Contains(t, logged, "fwduser")
// No warning when they agree.
buf.Reset()
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
req2.Header.Set("X-Auth-Request-User", "same")
req2.Header.Set("X-Forwarded-User", "same")
h.ServeHTTP(httptest.NewRecorder(), req2)
assert.Empty(t, buf.String(), "no warning expected when headers agree")
}
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
-79
View File
@@ -1,79 +0,0 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
// A nil JWTValidator always returns false — JWT validation is disabled.
type JWTValidator struct {
issuer string
aud string
cache *jwk.Cache
jwksURI string
}
// NewJWTValidator creates a validator by fetching the OIDC discovery document
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
if issuerURL == "" {
return nil, nil
}
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
if err != nil {
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var doc struct {
JWKSURI string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode oidc discovery: %w", err)
}
cache := jwk.NewCache(ctx)
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
return nil, fmt.Errorf("register jwks uri: %w", err)
}
// warm the cache immediately so first request doesn't block
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
return nil, fmt.Errorf("warm jwks cache: %w", err)
}
return &JWTValidator{
issuer: issuerURL,
aud: audience,
cache: cache,
jwksURI: doc.JWKSURI,
}, nil
}
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
if v == nil {
return false
}
keySet, err := v.cache.Get(ctx, v.jwksURI)
if err != nil {
return false
}
opts := []jwt.ParseOption{
jwt.WithKeySet(keySet),
jwt.WithIssuer(v.issuer),
jwt.WithValidate(true),
}
if v.aud != "" {
opts = append(opts, jwt.WithAudience(v.aud))
}
_, err = jwt.Parse([]byte(rawToken), opts...)
return err == nil
}
+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
}
+1 -1
View File
@@ -3,7 +3,7 @@ package config_test
import (
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"git.d-ma.be/mathias/gitea-mcp/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+58
View File
@@ -3,8 +3,11 @@ package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/hashicorp/golang-lru/v2/expirable"
@@ -17,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,
@@ -26,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
@@ -40,7 +75,21 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
return repo.DefaultBranch, nil
}
// hasEmptySegment reports whether the path portion (before any query string)
// contains an empty segment ("//"), which means an owner or repo path
// parameter was empty. Forwarding it upstream yields gitea's opaque
// /api/swagger 404 (#36), so callers reject it locally instead.
func hasEmptySegment(path string) bool {
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
return strings.Contains(path, "//")
}
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
if hasEmptySegment(path) {
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
@@ -50,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)
}
@@ -107,6 +159,9 @@ type rawResponse struct {
}
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
if hasEmptySegment(path) {
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
@@ -116,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)
}
+54
View File
@@ -0,0 +1,54 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// #36 second line of defence: when owner or repo is empty the path contains an
// empty segment ("//"). Rather than forward it upstream — where gitea answers
// with its opaque /api/swagger 404 — the client must reject it locally with a
// validation error and never touch the network.
func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
paths := []string{
"/api/v1/repos/mathias//issues", // empty repo
"/api/v1/repos//gitea-mcp/contents/", // empty owner
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
}
for _, p := range paths {
_, _, err := c.GetJSON(context.Background(), p)
require.Error(t, err, "path %q should be rejected", p)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "guard must short-circuit before any HTTP call")
}
// A well-formed path with a query string must still pass the guard.
func TestWellFormedPathPasses(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, status, err := c.GetJSON(context.Background(), "/api/v1/repos/mathias/gitea-mcp/issues?state=open")
require.NoError(t, err)
assert.Equal(t, 200, status)
}
+44 -1
View File
@@ -7,7 +7,7 @@ import (
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -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)
}
+151 -18
View File
@@ -2,31 +2,78 @@ package gitea_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"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
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+136 -15
View File
@@ -4,24 +4,27 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Comments int `json:"comments"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Comments int `json:"comments"`
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
}
type User struct {
@@ -72,10 +75,128 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
return &iss, nil
}
// ListIssuesArgs captures the optional query params for ListIssues.
type ListIssuesArgs struct {
State string // "open" | "closed" | "all"
Labels string // comma-separated label names
Since string // ISO 8601
Page int
Limit int
}
// ListIssues fetches issues for a repo. Pulls are excluded server-side
// (type=issues) so they don't leak through the same endpoint.
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
q := url.Values{}
q.Set("type", "issues")
if args.State != "" {
q.Set("state", args.State)
}
if args.Labels != "" {
q.Set("labels", args.Labels)
}
if args.Since != "" {
q.Set("since", args.Since)
}
if args.Page > 0 {
q.Set("page", strconv.Itoa(args.Page))
}
if args.Limit > 0 {
q.Set("limit", strconv.Itoa(args.Limit))
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var issues []Issue
if err := json.Unmarshal(body, &issues); err != nil {
return nil, err
}
return issues, nil
}
// SetIssueState flips an issue between "open" and "closed" via PATCH.
// Gitea uses the same endpoint for both transitions.
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
payload, err := json.Marshal(map[string]string{"state": state})
if err != nil {
return nil, err
}
body, status, err := c.PatchJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil,
// left untouched) from an explicit empty string (clears the field). Maps to
// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}.
type EditIssueArgs struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
// EditIssue patches an issue's title and/or body. Only fields set in args are
// sent, so omitted fields are left as-is server-side. Body is sent verbatim —
// no identity footer — so repeated edits are idempotent.
func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PatchJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
type IssueComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
User User `json:"user,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// ListIssueComments fetches all comments on an issue or pull request.
// Per Gitea, /issues/{index}/comments serves both since PRs share index space with issues.
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int) ([]IssueComment, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var comments []IssueComment
if err := json.Unmarshal(body, &comments); err != nil {
return nil, err
}
return comments, nil
}
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
+139 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -76,6 +76,96 @@ func TestGetIssue_NotFound(t *testing.T) {
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestEditIssue(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title, body := "new title", "new body"
iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "new title", payload["title"])
assert.Equal(t, "new body", payload["body"])
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "new title", iss.Title)
}
// EditIssue must send only the fields explicitly provided — an omitted field
// (nil pointer) is left untouched server-side.
func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title := "only title"
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "only title", payload["title"])
_, hasBody := payload["body"]
assert.False(t, hasBody, "body must be omitted when not set")
}
// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted.
func TestEditIssue_EmptyBodyIsSent(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
body := ""
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
val, hasBody := payload["body"]
assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field")
assert.Equal(t, "", val)
}
func TestEditIssue_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title := "x"
_, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestCreateIssueComment(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -101,3 +191,51 @@ func TestCreateIssueComment(t *testing.T) {
assert.Equal(t, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
}
func TestListIssueComments(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
require.Len(t, comments, 2)
assert.Equal(t, int64(1), comments[0].ID)
assert.Equal(t, "first", comments[0].Body)
assert.Equal(t, "alice", comments[0].User.Login)
assert.Equal(t, "bob", comments[1].User.Login)
}
func TestListIssueComments_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Empty(t, comments)
}
func TestListIssueComments_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
+48
View File
@@ -0,0 +1,48 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
// ListLabels fetches all labels defined on a repo.
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
// space with issues, per Gitea). This is additive per Gitea's own POST
// semantics — existing labels are left in place, no replace/delete needed.
// Returns the issue's full label set after the add.
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
+107
View File
@@ -0,0 +1,107 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListLabels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
require.Len(t, labels, 2)
assert.Equal(t, int64(1), labels[0].ID)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "ee0701", labels[0].Color)
assert.Equal(t, "enhancement", labels[1].Name)
}
func TestListLabels_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
assert.Empty(t, labels)
}
func TestListLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListLabels(context.Background(), "o", "r")
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestAddIssueLabels(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":3,"name":"priority","color":"00ff00"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
require.Len(t, ids, 1)
assert.Equal(t, float64(3), ids[0])
require.Len(t, labels, 2)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "priority", labels[1].Name)
}
func TestAddIssueLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2
View File
@@ -16,10 +16,12 @@ type PullRequest struct {
Draft bool `json:"draft"`
Head struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
Mergeable bool `json:"mergeable"`
}
type CreatePullRequestArgs struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+81 -31
View File
@@ -4,8 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
)
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
@@ -14,51 +14,45 @@ type DispatchWorkflowArgs struct {
Inputs map[string]any `json:"inputs,omitempty"`
}
// WorkflowRunTrigger holds the run ID extracted from the Location header.
type WorkflowRunTrigger struct {
RunID int64
}
// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID.
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) {
// DispatchWorkflow triggers a workflow_dispatch event. Gitea returns 204 No
// Content with NO Location header, so the response carries no run ID — callers
// resolve the new run separately via ListWorkflowRuns. Returns nil on success.
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) error {
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
return err
}
resp, err := c.doRaw(ctx, "POST", p, payload)
if err != nil {
return nil, err
return err
}
if resp.Status != 204 {
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
return nil, mapErr
return mapErr
}
return nil, fmt.Errorf("unexpected status %d", resp.Status)
return fmt.Errorf("unexpected status %d", resp.Status)
}
location := resp.Headers.Get("Location")
if location == "" {
return nil, fmt.Errorf("missing Location header in dispatch response")
}
// Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment.
parts := strings.Split(strings.TrimRight(location, "/"), "/")
if len(parts) == 0 {
return nil, fmt.Errorf("malformed Location: %s", location)
}
runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return nil, fmt.Errorf("parse run id from %q: %w", location, err)
}
return &WorkflowRunTrigger{RunID: runID}, nil
return nil
}
// WorkflowRun represents a Gitea Actions run.
type WorkflowRun struct {
ID int64 `json:"id"`
Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
StartedAt string `json:"started_at"`
HTMLURL string `json:"html_url"`
ID int64 `json:"id"`
DisplayTitle string `json:"display_title,omitempty"`
Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
Event string `json:"event,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
HeadBranch string `json:"head_branch,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
RunNumber int64 `json:"run_number,omitempty"`
StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at,omitempty"`
HTMLURL string `json:"html_url"`
Actor struct {
Login string `json:"login"`
} `json:"actor,omitempty"`
}
// GetWorkflowRun fetches the status of a specific Actions run.
@@ -77,3 +71,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
}
return &run, nil
}
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
type ListWorkflowRunsArgs struct {
Branch string
HeadSHA string
Status string // queued | in_progress | completed | all
Event string // push | pull_request | schedule | workflow_dispatch | all
Workflow string
Page int
Limit int
}
type workflowRunsResponse struct {
TotalCount int64 `json:"total_count"`
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
}
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
// Status / Event of "all" or "" are treated as no-filter.
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
q := url.Values{}
if args.Branch != "" {
q.Set("branch", args.Branch)
}
if args.HeadSHA != "" {
q.Set("head_sha", args.HeadSHA)
}
if args.Status != "" && args.Status != "all" {
q.Set("status", args.Status)
}
if args.Event != "" && args.Event != "all" {
q.Set("event", args.Event)
}
if args.Workflow != "" {
q.Set("workflow", args.Workflow)
}
if args.Page > 0 {
q.Set("page", strconv.Itoa(args.Page))
}
if args.Limit > 0 {
q.Set("limit", strconv.Itoa(args.Limit))
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var resp workflowRunsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+7 -18
View File
@@ -9,11 +9,14 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Gitea's dispatch endpoint returns 204 No Content with NO Location header.
// Dispatch must succeed and forward ref+inputs in the body; the run ID is
// resolved separately by the tool via ListWorkflowRuns.
func TestDispatchWorkflow(t *testing.T) {
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -22,18 +25,17 @@ func TestDispatchWorkflow(t *testing.T) {
var err error
gotBody, err = io.ReadAll(r.Body)
assert.NoError(t, err)
w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789")
// No Location header — matches real Gitea.
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
Ref: "main",
Inputs: map[string]any{"env": "prod"},
})
require.NoError(t, err)
assert.Equal(t, int64(789), result.RunID)
var body map[string]any
require.NoError(t, json.Unmarshal(gotBody, &body))
@@ -43,19 +45,6 @@ func TestDispatchWorkflow(t *testing.T) {
assert.Equal(t, "prod", inputs["env"])
}
func TestDispatchWorkflowMissingLocation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 204 but no Location header
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Location")
}
func TestDispatchWorkflowError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -63,7 +52,7 @@ func TestDispatchWorkflowError404(t *testing.T) {
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound))
}
+1 -1
View File
@@ -3,7 +3,7 @@ package identity_test
import (
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const (
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"sync"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
)
+74
View File
@@ -0,0 +1,74 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// #38: `repo` (and `number`) are the canonical, idiomatic gitea/GitHub arg names
// that identifier tools advertise. `name` is kept as a back-compat alias via the
// bidirectional shim, so old `name` callers still work. `index`->`number` stays.
// An explicit canonical (`repo`) always wins over its alias (`name`).
func TestRepoAndIndexAliasesResolve(t *testing.T) {
tests := []struct {
name string
args string
wantPath string
}{
{"canonical repo+number", `{"owner":"mathias","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"repo+index alias", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"legacy name alias", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"explicit repo wins over name", `{"owner":"mathias","name":"ignored","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":7,"title":"t"}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.args))
require.NoError(t, err)
assert.Equal(t, tc.wantPath, gotPath)
assert.Contains(t, string(out), `"number":7`)
})
}
}
// #38: identifier tools must ADVERTISE `repo` (not `name`) in their schema, so
// the contract a client reads matches what every caller sends. The two create
// tools keep `name` because there it means "name of the new repo", not an
// existing-repo identifier.
func TestRepoIsCanonicalInAdvertisedSchema(t *testing.T) {
c := gitea.NewClient("http://unused", "")
a := allowlist.New([]string{"mathias"})
identifier := map[string]json.RawMessage{
"repo_get": tools.NewRepoGet(c, a).Descriptor().InputSchema,
"issue_get": tools.NewIssueGet(c, a).Descriptor().InputSchema,
"pr_merge": tools.NewPRMerge(c, a).Descriptor().InputSchema,
"repo_delete": tools.NewRepoDelete(c, a).Descriptor().InputSchema,
"file_read": tools.NewFileRead(c, a).Descriptor().InputSchema,
}
for name, sch := range identifier {
s := string(sch)
assert.Contains(t, s, `"repo":`, name+" must advertise repo")
assert.NotContains(t, s, `"name":`, name+" must not advertise name")
}
// create tools keep `name` (new resource name, not an existing-repo id)
assert.Contains(t, string(tools.NewRepoCreate(c, a).Descriptor().InputSchema), `"name":`)
}
+8 -8
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchDelete struct {
@@ -27,17 +27,17 @@ func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","name","branch"]
"required":["owner","repo","branch"]
}`),
}
}
type branchDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Branch string `json:"branch"`
}
@@ -46,14 +46,14 @@ 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 == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
}
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
if err := t.c.DeleteBranch(ctx, args.Owner, args.Repo, args.Branch); err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchList struct {
@@ -26,18 +26,18 @@ func (t *BranchList) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","name"]
"required":["owner","repo"]
}`),
}
}
type branchListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Page int `json:"page"`
Limit int `json:"limit"`
}
@@ -47,11 +47,11 @@ 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
}
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
branches, err := t.c.ListBranches(ctx, args.Owner, args.Repo, args.Page, capLimit(args.Limit, 30))
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchProtectionGet struct {
@@ -26,17 +26,17 @@ func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","name","branch"]
"required":["owner","repo","branch"]
}`),
}
}
type branchProtectionGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Branch string `json:"branch"`
}
@@ -45,11 +45,11 @@ 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
}
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, args.Branch)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+5 -5
View File
@@ -8,9 +8,9 @@ import (
"sync"
"time"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type semaphore chan struct{}
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
type codeSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Repo string `json:"repo,omitempty"` // optional: empty => owner-wide fan-out (#37 opt-out)
Page int `json:"page"`
Limit int `json:"limit"`
}
@@ -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 {
+111 -78
View File
@@ -2,36 +2,92 @@ package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"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)
}
+377 -45
View File
@@ -2,34 +2,37 @@ package tools
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
var substitutionFiles = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func substitutions(owner, name string) map[string]string {
return map[string]string{
"__PROJECT_NAME__": name,
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
// git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be
// rename; a stale host breaks `go mod download` for downstream consumers).
"__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name,
}
}
func applyReplacements(s string, repls map[string]string) string {
for k, v := range repls {
s = strings.ReplaceAll(s, k, v)
}
return s
}
// CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct {
c *gitea.Client
@@ -45,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "create_project_from_template",
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
@@ -53,7 +56,9 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"},
"private":{"type":"boolean"},
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
"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 (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"]
}`),
@@ -61,22 +66,44 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
}
type createProjectArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
TemplateName string `json:"template_name"`
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
TemplateName string `json:"template_name"`
DispatchAllow bool `json:"dispatch_allow"`
Resume bool `json:"resume"`
}
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
// sandbox convention: presence of the file (not its content) marks the repo
// dispatch-eligible; the comment exists only to explain that to a human reader.
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
"# 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"`
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 {
@@ -84,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
}
@@ -93,6 +120,94 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
}
var result createProjectResult
var branch string
var err error
if args.Resume {
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
} else {
result, branch, err = t.createDestination(ctx, args)
}
if err != nil {
return nil, err
}
// Substitute across the WHOLE tree: content in every blob, plus a path rename
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
// A fixed known-files list can't rename directories or cover every templated
// file, which is why the old scaffold didn't build. GetTree reflects the
// branch's CURRENT state, which is what makes this loop safe to re-run on
// resume: a file already fixed in a prior partial pass shows up already-correct
// (or already-renamed) and substituteEntry is a no-op for it.
repls := substitutions(args.Owner, args.Name)
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
if terr != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
return textOK(result)
}
for _, e := range tree.Tree {
if e.Type != "blob" {
continue
}
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
if fail != "" {
result.PartialFailure = fail
break
}
if substituted != "" {
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
}
}
// 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 == "" {
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,
// the repo IS created — say so clearly and point to the concrete recovery step
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
// gitea's template-generate is slow-async on this instance, so tool-side
// substitution is best-effort).
if strings.Contains(result.PartialFailure, "branch does not exist") ||
strings.Contains(result.PartialFailure, "not found") {
result.PartialFailure = infra179FinalizeMessage(
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud on a FRESH create with nothing substituted: a real template
// should have placeholders, so finding none is suspicious. On resume, nothing
// left to substitute is the expected steady state once a prior partial run is
// fully caught up — success, not a loud failure.
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
}
return textOK(result)
}
// createDestination generates a new repo from the template: verifies the
// template exists and the destination doesn't already exist, then calls
// gitea's /generate and resolves the default branch.
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
// Resolve template: per-call override takes precedence over the
// server-configured default. Owner stays server-configured.
tmplName := args.TemplateName
@@ -103,17 +218,19 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
// Verify template exists and is marked as a template repo.
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
if err != nil {
return nil, fmt.Errorf("template lookup: %w", err)
return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
}
if !tmpl.Template {
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
}
// Verify destination doesn't already exist.
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
return createProjectResult{}, "", fmt.Errorf(
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) {
return nil, fmt.Errorf("destination check: %w", err)
return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
}
// Generate repo from template.
@@ -125,7 +242,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
GitContent: true,
})
if err != nil {
return nil, fmt.Errorf("generate: %w", err)
return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
}
result := createProjectResult{
@@ -135,21 +252,236 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
DefaultBranch: newRepo.DefaultBranch,
}
// Substitute placeholders in known files (best-effort).
repls := substitutions(args.Owner, args.Name)
// The /generate response often omits default_branch — resolve it explicitly,
// otherwise every file read below hits an empty ref and nothing substitutes
// (the silent-null bug: gitea-mcp#42).
branch := newRepo.DefaultBranch
for _, path := range substitutionFiles {
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
// Files that don't exist in this template are silently skipped.
if errors.Is(err, gitea.ErrNotFound) {
continue
}
// Any other error halts the substitution pass with partial_failure recorded.
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
break
if branch == "" {
if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
branch = r.DefaultBranch
} else {
branch = "main"
}
result.FilesSubstituted = append(result.FilesSubstituted, path)
}
result.DefaultBranch = branch
return result, branch, nil
}
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
// nothing to resume without it. Template lookup and generation are skipped
// entirely: resume only ever operates on the destination.
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
repo, err := t.c.GetRepo(ctx, owner, name)
if err != nil {
if errors.Is(err, gitea.ErrNotFound) {
return createProjectResult{}, "", fmt.Errorf(
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
owner, name, gitea.ErrValidation)
}
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
}
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
return createProjectResult{
FullName: repo.FullName,
HTMLURL: repo.HTMLURL,
CloneURL: repo.CloneURL,
DefaultBranch: branch,
}, branch, nil
}
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
// on every resume, not just the first attempt): creates it if absent, updates
// it if present but different, leaves it untouched if already correct. Without
// this, a naive create-only write would error on a re-invoke (gitea rejects a
// create at a path that already exists) — that was the reported failure in
// gitea-mcp#51. Returns whether a write actually happened.
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
const path = ".dispatch-allow"
sha := ""
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
return false, "" // already present and correct — idempotent no-op
}
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
} else if !errors.Is(err, gitea.ErrNotFound) {
return false, fmt.Sprintf("read %s: %v", path, err)
}
return textOK(result)
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
Sha: sha,
}); err != nil {
return false, fmt.Sprintf("write %s: %v", path, err)
}
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
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
// resume safely continues from wherever substitution stalled.
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
return fmt.Sprintf(
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
"template-generate is slow-async on this instance (infra#179), so substitution "+
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
"(same owner/name) once the branch is writable — it safely continues where this left "+
"off, skipping anything already correct. Underlying: %s",
branch, budget, done, underlying)
}
// substitutionBudget bounds how long we retry the first write while the freshly
// generated branch becomes writable. gitea's /generate returns (and serves reads)
// before the branch ref is committed, so writes 404 "branch does not exist" for a
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
// fails fast with partial_failure naming resume=true as the recovery path
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
const substitutionBudget = 5
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
// exist" not-found, up to substitutionBudget. The write itself is the readiness
// probe — BranchExists reports the branch present before writes succeed.
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
var err error
for i := 0; i < substitutionBudget; i++ {
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
return nil
}
if !errors.Is(err, gitea.ErrNotFound) {
return err
}
select {
case <-ctx.Done():
return err
case <-time.After(time.Second):
}
}
return err
}
// substituteEntry substitutes placeholders in one blob. If the path carries a
// placeholder it renames the file (write new + delete old); otherwise it rewrites
// content in place when changed. Returns a human-readable description of what was
// substituted ("" if nothing), and a non-empty partial-failure string on error.
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
newPath := applyReplacements(path, repls)
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
if err != nil {
if errors.Is(err, gitea.ErrNotFound) {
return "", "" // vanished between tree walk and read; skip
}
return "", fmt.Sprintf("read %s: %v", path, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return "", fmt.Sprintf("decode %s: %v", path, err)
}
newContent := applyReplacements(string(decoded), repls)
renamed := newPath != path
changed := newContent != string(decoded)
if !renamed && !changed {
return "", "" // nothing to do
}
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
if renamed {
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: "template: substitute placeholders",
Sha: fc.Sha,
}); err != nil {
return "", fmt.Sprintf("write %s: %v", path, err)
}
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, ""
}
@@ -5,318 +5,572 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
var substitutionFileList = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// contentWithPlaceholder is a template file body that contains the placeholder.
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
func encodedContent(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// fileContentsJSON returns a JSON FileContents object for the given path.
func fileContentsJSON(path string) string {
enc := encodedContent(contentWithPlaceholder)
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), enc)
}
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
func fileWriteResultJSON(path string) string {
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
}
// newTemplateRepoJSON returns a JSON Repo marked as template.
func newTemplateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
func templateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
name, name, name, name, isTemplate)
}
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
func newGeneratedRepoJSON(name string) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
name, name, name, name)
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
type fakeTemplateServer struct {
mu sync.Mutex
files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool
generateCalls int // # times POST .../generate was hit — resume must never increment this
puts []string
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 newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
c := gitea.NewClient(srvURL, "tok")
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
}
// TestCreateProjectHappyPath: all 6 files served and substituted.
func TestCreateProjectHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// newFakeTemplateServerResumed simulates a repo that already exists from a
// prior (real) generate call — GET dest succeeds immediately, without a
// /generate call first. Used for resume:true tests.
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
f := newFakeTemplateServer(files, genBranch)
f.generated = true
return f
}
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
switch {
// Template repo lookup
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
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))
// Destination repo lookup — 404 means it doesn't exist yet
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
// Generate
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
// File contents GET — handle all 6 substitution files
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
// File contents PUT — handle all 6 substitution files
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
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(fileWriteResultJSON(filePath)))
_, _ = 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)))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
if !f.generated {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
f.repoGetsPost++
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
f.generated = true
f.generateCalls++
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
dest, dest, f.genBranch, dest, dest)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
var entries []string
for path := range f.files {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
}
// include a tree (directory) entry to exercise the blob filter
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
body, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.puts = append(f.puts, path)
f.putBodies[path] = string(dec)
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
f.deletes = append(f.deletes, path)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}))
}
}
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate {
return tools.NewCreateProjectFromTemplate(
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
}
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
t.Helper()
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
require.NoError(t, err)
var out createOut
require.NoError(t, json.Unmarshal(res, &out))
return out
}
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"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// Happy path: whole-tree substitution, content + path rename, correct module host.
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) {
files := map[string]string{
"go.mod": "module __MODULE_PATH__\n\ngo 1.26\n",
"README.md": "# __PROJECT_NAME__\n",
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
"pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched
}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
require.NoError(t, err)
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
var out 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"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.Equal(t, "mathias/new-svc", out.FullName)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
assert.Equal(t, "main", out.DefaultBranch)
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure)
// content-substituted files present; untouched file absent
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
// path rename recorded as "old -> new"
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
// rename: new path written, old path deleted
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
f.putBodies["cmd/new-svc/main.go"])
// the untouched file was never written
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
}
// The /generate response omits default_branch (the live gitea behavior the old
// mock hid) → tool must re-fetch the repo and still substitute.
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
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.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
assert.Empty(t, out.PartialFailure)
}
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
// server-configured default, so the same binary can generate from template-go-web or
// template-go-agent without restart.
func TestCreateProjectTemplateNameOverride(t *testing.T) {
var templateLookups, generateCalls []string
// Fail loud: a template whose files carry no placeholders yields nothing
// substituted — surface it rather than returning silent success.
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
files := map[string]string{"README.md": "# static, no placeholders\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.FilesSubstituted)
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
}
// Write failure mid-pass → partial_failure populated, no Go error.
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
templateLookups = append(templateLookups, "template-go-agent")
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
templateLookups = append(templateLookups, "template-go-web")
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
generateCalls = append(generateCalls, r.URL.Path)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
))
require.NoError(t, err)
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
"override must direct the template lookup, not the server default")
require.Len(t, generateCalls, 1)
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
"override must direct the /generate call too")
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.NotEmpty(t, out.PartialFailure)
assert.Contains(t, out.PartialFailure, "go.mod")
}
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
func TestCreateProjectNameRegexFailure(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
func TestCreateProject_DispatchAllow(t *testing.T) {
tests := []struct {
name string
argsJSON string
wantFile bool
}{
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(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", tc.argsJSON)
require.Empty(t, out.PartialFailure)
if tc.wantFile {
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
assert.Contains(t, f.puts, ".dispatch-allow")
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
} else {
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
assert.NotContains(t, f.puts, ".dispatch-allow")
}
})
}
}
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
// resume:true requires an ALREADY-CREATED destination — there is nothing to
// resume otherwise.
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), "nothing to resume")
}
// resume:true on an existing repo continues substitution — fixes only what's
// still wrong, leaves already-correct files untouched, and never calls
// /generate again (the whole point: no re-creation, just continuation).
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
}
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.Equal(t, 0, f.generateCalls, "resume must never call /generate")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
assert.Contains(t, f.puts, "README.md")
}
// resume:true when everything is already substituted is the expected steady
// state (a prior resume already finished the job, or this is a redundant
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
// that a fresh (non-resume) create would trigger.
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
"README.md": "# 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.FilesSubstituted)
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
// create-only write would (gitea 409/422 on an existing path with no sha).
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
// Phase 1: a normal (non-resume) call captures the REAL content the tool
// writes for .dispatch-allow, without the test needing to know the exact
// unexported constant.
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
seedSrv := newFakeTemplateServer(seedFiles, "main")
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
srv1.Close()
require.Empty(t, out1.PartialFailure)
realContent := seedSrv.putBodies[".dispatch-allow"]
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
// one file still needing substitution.
files := map[string]string{
".dispatch-allow": realContent,
"README.md": "# __PROJECT_NAME__\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv2.Close()
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out2.PartialFailure)
assert.Empty(t, out2.DispatchAllowFailure)
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
assert.Contains(t, out2.FilesSubstituted, "README.md")
}
// dispatch_allow injection failing is reported in its OWN field, distinct from
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
// eligibility still fails, and the caller must be able to tell them apart.
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
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 itself succeeded — must not be conflated with the dispatch failure")
assert.NotEmpty(t, out.DispatchAllowFailure)
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
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) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
func TestCreateProjectAllowlistRejects(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
func TestCreateProject_AllowlistRejects(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist")
}
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
func TestCreateProject_NotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Template lookup returns a non-template repo.
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
// TestCreateProjectDestinationExists: destination repo already exists.
func TestCreateProjectDestinationExists(t *testing.T) {
func TestCreateProject_DestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
// Destination exists — return 200.
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
switch r.URL.Path {
case "/api/v1/repos/mathias/template-go-agent":
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
case "/api/v1/repos/mathias/new-svc":
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict)
}
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
// Files that should succeed (index 0-2 in substitutionFileList).
successFiles := map[string]bool{
"go.mod": true,
"Taskfile.yml": true,
"Dockerfile": true,
}
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
failFile := ".gitea/workflows/cd.yml"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
if filePath == failFile {
// Simulate upstream 500.
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
return
}
if !successFiles[filePath] {
t.Errorf("unexpected PUT for file: %s", filePath)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
// Best-effort: no Go error returned, partial state in result.
require.NoError(t, err)
var out struct {
FullName string `json:"full_name"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
// First 3 files should be in FilesSubstituted.
assert.Len(t, out.FilesSubstituted, 3)
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
assert.NotContains(t, out.FilesSubstituted, failFile)
// partial_failure should be non-empty.
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
}
@@ -0,0 +1,23 @@
package tools
import (
"strings"
"testing"
)
// #46: the infra#179 finalize guidance must not point at a non-existent command
// (`hyperguild new-project` was never built). #50 superseded the original
// manual-editing guidance with a concrete, real recovery: re-invoke this same
// tool with resume=true.
func TestInfra179FinalizeMessage(t *testing.T) {
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q\ngot: %s", want, msg)
}
}
if strings.Contains(msg, "hyperguild new-project") {
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
}
}
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type DirList struct {
@@ -26,18 +26,18 @@ func (t *DirList) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","name"]
"required":["owner","repo"]
}`),
}
}
type dirListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Path string `json:"path"`
Ref string `json:"ref"`
}
@@ -47,11 +47,11 @@ 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
}
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
entries, err := t.c.ListContents(ctx, args.Owner, args.Repo, args.Path, args.Ref)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+8 -8
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type FileDelete struct {
@@ -27,20 +27,20 @@ func (t *FileDelete) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"path":{"type":"string"},
"branch":{"type":"string"},
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","name","path","branch","message","sha"]
"required":["owner","repo","path","branch","message","sha"]
}`),
}
}
type fileDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Path string `json:"path"`
Branch string `json:"branch"`
Message string `json:"message"`
@@ -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 == "" {
@@ -62,7 +62,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
}
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
result, err := t.c.DeleteFile(ctx, args.Owner, args.Repo, args.Path, gitea.DeleteFileArgs{
Branch: args.Branch,
Message: args.Message,
Sha: args.Sha,
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+9 -9
View File
@@ -6,9 +6,9 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const fileReadMaxBytes = 1 << 20 // 1 MiB
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","name","path"]
"required":["owner","repo","path"]
}`),
}
}
type fileReadArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Path string `json:"path"`
Ref string `json:"ref"`
}
@@ -51,20 +51,20 @@ 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
}
ref := args.Ref
if ref == "" {
var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
}
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, ref)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+12 -12
View File
@@ -6,9 +6,9 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type FileWriteBranch struct {
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "file_write_branch",
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
Description: "Create or update a file on the given branch. Pass an existing branch — e.g. the default branch `main` — to commit directly to it (trunk-based); a branch that doesn't exist yet is created from `base` first (PR flow). Pair with pr_create/pr_merge for the branch→PR→merge path.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"path":{"type":"string"},
"content":{"type":"string"},
"branch":{"type":"string"},
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","name","path","content","branch","message"]
"required":["owner","repo","path","content","branch","message"]
}`),
}
}
type fileWriteBranchArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Path string `json:"path"`
Content string `json:"content"`
Branch string `json:"branch"`
@@ -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 == "" {
@@ -68,7 +68,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
}
// Resolve base default if branch needs to be created
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
exists, err := t.c.BranchExists(ctx, args.Owner, args.Repo, args.Branch)
if err != nil {
return nil, err
}
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
base := args.Base
if base == "" {
var err error
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
}
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, args.Branch, base); err != nil {
return nil, err
}
}
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
result, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
Branch: args.Branch,
Content: encoded,
Message: args.Message,
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueClose struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
return &IssueClose{c: c, a: a}
}
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_close",
Description: "Close an open issue. Reversible via issue_reopen.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueCloseArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCloseArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
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")
if err != nil {
return nil, err
}
return textOK(iss)
}
+52
View File
@@ -0,0 +1,52 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueCloseTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"closed"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"closed"`)
}
func TestIssueCloseTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueCloseAllowlistRejects(t *testing.T) {
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueComment struct {
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
"required":["owner","repo","number","body"]
}`),
}
}
type issueCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Number int `json:"number"`
Body string `json:"body"`
}
@@ -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 {
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueCreate struct {
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"labels":{"type":"array","items":{"type":"integer"}},
"assignees":{"type":"array","items":{"type":"string"}},
"milestone":{"type":"integer"}
},
"required":["owner","name","title"]
"required":["owner","repo","title"]
}`),
}
}
type issueCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels"`
@@ -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 == "" {
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Repo, gitea.CreateIssueArgs{
Title: args.Title,
Body: body,
Labels: args.Labels,
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueEdit struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit {
return &IssueEdit{c: c, a: a}
}
func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_edit",
Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " +
"omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " +
"WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " +
"(fetch with issue_get, edit the text, send it back).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
},
"required":["owner","repo","number"]
}`),
}
}
type issueEditArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueEditArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation)
}
if args.Title == nil && args.Body == nil {
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
}
iss, err := t.c.EditIssue(ctx, args.Owner, args.Repo, args.Number, gitea.EditIssueArgs{
Title: args.Title,
Body: args.Body,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": iss.Number,
"title": iss.Title,
"html_url": iss.HTMLURL,
"state": iss.State,
})
}
+91
View File
@@ -0,0 +1,91 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueEditTool(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "new", payload["title"])
assert.Equal(t, "updated body", payload["body"])
assert.Contains(t, string(out), `"number":26`)
}
// Body must be sent verbatim — no identity footer appended (keeps edits idempotent).
func TestIssueEditTool_BodyVerbatim(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"state":"open"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer")
_, hasTitle := payload["title"]
assert.False(t, hasTitle, "title must be omitted when not provided")
}
func TestIssueEditTool_RequiresAField(t *testing.T) {
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestIssueEditTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
title := "x"
body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title})
_, err := tool.Call(context.Background(), body)
require.Error(t, err)
}
func TestIssueEditAllowlistRejects(t *testing.T) {
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`))
require.Error(t, err)
}
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueGet struct {
@@ -24,17 +24,17 @@ func (t *IssueGet) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
"required":["owner","repo","number"]
}`),
}
}
type issueGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Number int `json:"number"`
}
@@ -43,10 +43,10 @@ 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.Name, args.Number)
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+87
View File
@@ -0,0 +1,87 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueLabel struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
return &IssueLabel{c: c, a: a}
}
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_label",
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"labels":{"type":"array","items":{"type":"string"},"description":"Label names to resolve and apply. Either labels or label_ids is required."},
"label_ids":{"type":"array","items":{"type":"integer"},"description":"Label IDs to apply directly, skipping name resolution. Either labels or label_ids is required."}
},
"required":["owner","repo","number"]
}`),
}
}
type issueLabelArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Labels []string `json:"labels,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
}
func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueLabelArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
if len(args.Labels) == 0 && len(args.LabelIDs) == 0 {
return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation)
}
ids := append([]int64{}, args.LabelIDs...)
if len(args.Labels) > 0 {
existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
byName := make(map[string]int64, len(existing))
for _, l := range existing {
byName[l.Name] = l.ID
}
for _, name := range args.Labels {
id, ok := byName[name]
if !ok {
return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation)
}
ids = append(ids, id)
}
}
labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids)
if err != nil {
return nil, err
}
return textOK(labels)
}
+128
View File
@@ -0,0 +1,128 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const labelListFixture = `[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`
func TestIssueLabelAppliesByName(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
// label_ids alone (no labels) must work end-to-end without hitting ListLabels
// at all — this is the schema-level "either labels or label_ids" contract, and
// it must never require a GET to the label list when the caller already has IDs.
func TestIssueLabelAppliesByIDOnly(t *testing.T) {
var captured []byte
var listCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
listCalled = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"label_ids":[1,2]}`))
require.NoError(t, err)
assert.False(t, listCalled, "label_ids-only must not call ListLabels")
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
}
// #52 review finding: the advertised schema wrongly required "labels", making
// label_ids-only calls fail JSON-Schema validation before reaching Call at all.
// Lock the fixed contract: neither is individually required.
func TestIssueLabelSchema_NeitherLabelsNorLabelIDsRequired(t *testing.T) {
sch := string(tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})).Descriptor().InputSchema)
assert.NotContains(t, sch, `"required":["owner","repo","number","labels"]`)
assert.Contains(t, sch, `"required":["owner","repo","number"]`)
}
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), `"does-not-exist"`)
assert.Contains(t, err.Error(), "o/r")
}
func TestIssueLabelAllowlistRejects(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
require.Error(t, err)
}
func TestIssueLabelRequiresValidNumber(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
+83
View File
@@ -0,0 +1,83 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
return &IssueList{c: c, a: a}
}
func (t *IssueList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_list",
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"state":{"type":"string","enum":["open","closed","all"]},
"labels":{"type":"string"},
"since":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","repo"]
}`),
}
}
type issueListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
State string `json:"state"`
Labels string `json:"labels"`
Since string `json:"since"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.State == "" {
args.State = "open"
}
args.Limit = capLimit(args.Limit, 30)
if args.Page < 1 {
args.Page = 1
}
issues, err := t.c.ListIssues(ctx, args.Owner, args.Repo, gitea.ListIssuesArgs{
State: args.State,
Labels: args.Labels,
Since: args.Since,
Page: args.Page,
Limit: args.Limit,
})
if err != nil {
return nil, err
}
out := map[string]any{
"issues": issues,
}
if len(issues) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueListComments struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueListComments(c *gitea.Client, a *allowlist.Allowlist) *IssueListComments {
return &IssueListComments{c: c, a: a}
}
func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_list_comments",
Description: "List all comments on an issue or pull request. Returns id, body, author, html_url, and timestamps for each comment.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueListCommentsArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueListCommentsArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
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)
if err != nil {
return nil, err
}
return textOK(comments)
}
@@ -0,0 +1,67 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueListCommentsTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42/comments", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"body":"first","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"second","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"id":1`)
assert.Contains(t, string(out), `"body":"first"`)
assert.Contains(t, string(out), `"login":"alice"`)
assert.Contains(t, string(out), `"login":"bob"`)
}
func TestIssueListCommentsTool_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `[]`)
}
func TestIssueListCommentsTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueListCommentsAllowlistRejects(t *testing.T) {
tool := tools.NewIssueListComments(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+88
View File
@@ -0,0 +1,88 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueListTool(t *testing.T) {
tests := []struct {
name string
input string
wantQuery map[string]string
respBody string
assert func(t *testing.T, out string)
}{
{
name: "happy path defaults",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"number":42`)
assert.Contains(t, out, `"number":41`)
},
},
{
name: "state filter",
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
wantQuery: map[string]string{"type": "issues", "state": "closed"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
},
},
{
name: "label + since filter",
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
respBody: `[]`,
assert: func(t *testing.T, out string) {},
},
{
name: "empty result",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"state": "open"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
assert.NotContains(t, out, `next_page`)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
q := r.URL.Query()
for k, v := range tc.wantQuery {
assert.Equal(t, v, q.Get(k), "query param %q", k)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.respBody))
}))
defer srv.Close()
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
require.NoError(t, err)
tc.assert(t, string(out))
})
}
}
func TestIssueListAllowlistRejects(t *testing.T) {
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueReopen struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
return &IssueReopen{c: c, a: a}
}
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_reopen",
Description: "Reopen a closed issue. Reversible via issue_close.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueReopenArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueReopenArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
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")
if err != nil {
return nil, err
}
return textOK(iss)
}
+40
View File
@@ -0,0 +1,40 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueReopenTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"open"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"open"`)
}
func TestIssueReopenAllowlistRejects(t *testing.T) {
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+54
View File
@@ -0,0 +1,54 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type LabelList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
return &LabelList{c: c, a: a}
}
func (t *LabelList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "label_list",
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"}
},
"required":["owner","repo"]
}`),
}
}
type labelListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args labelListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
return textOK(labels)
}
+42
View File
@@ -0,0 +1,42 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLabelListTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"id":1`)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"color":"ee0701"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
func TestLabelListAllowlistRejects(t *testing.T) {
tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`))
require.Error(t, err)
}
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRComment struct {
@@ -29,18 +29,18 @@ func (t *PRComment) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
"required":["owner","repo","number","body"]
}`),
}
}
type prCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Number int `json:"number"`
Body string `json:"body"`
}
@@ -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 {
@@ -61,7 +61,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRCreate struct {
@@ -29,21 +29,21 @@ func (t *PRCreate) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"head":{"type":"string"},
"base":{"type":"string"},
"draft":{"type":"boolean"}
},
"required":["owner","name","title","head","base"]
"required":["owner","repo","title","head","base"]
}`),
}
}
type prCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Title string `json:"title"`
Body string `json:"body"`
Head string `json:"head"`
@@ -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 == "" {
@@ -68,7 +68,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Name, gitea.CreatePullRequestArgs{
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Repo, gitea.CreatePullRequestArgs{
Title: args.Title,
Body: body,
Head: args.Head,
+5 -5
View File
@@ -9,10 +9,10 @@ import (
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -30,7 +30,7 @@ const prFixture = `{
func callerContext(user string) context.Context {
var capturedCtx context.Context
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
h := auth.CallerMiddleware(nil, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedCtx = r.Context()
}))
req := httptest.NewRequest("POST", "/", nil)
+9 -9
View File
@@ -8,9 +8,9 @@ import (
"fmt"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const (
@@ -35,17 +35,17 @@ func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
"required":["owner","repo","number"]
}`),
}
}
type prFilesDiffArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Repo string `json:"repo"`
Number int `json:"number"`
}
@@ -63,19 +63,19 @@ 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 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, err
}
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Some files were not shown because too many files have changed in this diff Show More