Compare commits

..
18 Commits
Author SHA1 Message Date
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
28 changed files with 1556 additions and 586 deletions
+54 -8
View File
@@ -27,6 +27,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. 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. 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. 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 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness.
PR flow only when a human reviewer outside the project is required. Document PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md. 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 ## Default stack
| Layer | Default | Fallback | Last resort | | Layer | Default | Fallback | Last resort |
@@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **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 - **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 ## Infrastructure
Three machines on Tailscale: Three machines on Tailscale:
@@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **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 | | **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":"..."}` | | **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://gitea.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files | | **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`. - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
@@ -219,15 +263,17 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/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: | Task type | Load |
- **TDD**: always write tests first — load `tdd` skill |-----------|------|
- **Code Review**: load `code-review` skill before any review | Any feature or bug fix | `tdd` |
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work | Refactor or design | `clean-code` or `solid` |
- **Problem first**: load `problem-analysis` skill before coding non-trivial features | Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
+54 -8
View File
@@ -32,6 +32,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. 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. 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. 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 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -54,6 +62,22 @@ These rules apply to every task across every project, regardless of harness.
PR flow only when a human reviewer outside the project is required. Document PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md. 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 ## Default stack
| Layer | Default | Fallback | Last resort | | Layer | Default | Fallback | Last resort |
@@ -83,6 +107,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **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 - **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 ## Infrastructure
Three machines on Tailscale: Three machines on Tailscale:
@@ -162,7 +206,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **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 | | **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":"..."}` | | **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://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **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`. - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
@@ -224,15 +268,17 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/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: | Task type | Load |
- **TDD**: always write tests first — load `tdd` skill |-----------|------|
- **Code Review**: load `code-review` skill before any review | Any feature or bug fix | `tdd` |
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work | Refactor or design | `clean-code` or `solid` |
- **Problem first**: load `problem-analysis` skill before coding non-trivial features | Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
+54 -8
View File
@@ -30,6 +30,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. 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. 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. 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 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -52,6 +60,22 @@ These rules apply to every task across every project, regardless of harness.
PR flow only when a human reviewer outside the project is required. Document PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md. 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 ## Default stack
| Layer | Default | Fallback | Last resort | | Layer | Default | Fallback | Last resort |
@@ -81,6 +105,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **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 - **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 ## Infrastructure
Three machines on Tailscale: Three machines on Tailscale:
@@ -160,7 +204,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **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 | | **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":"..."}` | | **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://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **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`. - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
@@ -222,15 +266,17 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/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: | Task type | Load |
- **TDD**: always write tests first — load `tdd` skill |-----------|------|
- **Code Review**: load `code-review` skill before any review | Any feature or bug fix | `tdd` |
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work | Refactor or design | `clean-code` or `solid` |
- **Problem first**: load `problem-analysis` skill before coding non-trivial features | Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
name: CD name: CD
on: "on":
push: push:
branches: [main] branches: [main]
tags: ["v*"] tags: ["v*"]
+54 -8
View File
@@ -27,6 +27,14 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. 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. 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. 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 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness.
PR flow only when a human reviewer outside the project is required. Document PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md. 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 ## Default stack
| Layer | Default | Fallback | Last resort | | Layer | Default | Fallback | Last resort |
@@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **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 - **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 ## Infrastructure
Three machines on Tailscale: Three machines on Tailscale:
@@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **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 | | **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":"..."}` | | **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://gitea.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files | | **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`. - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
@@ -219,15 +263,17 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/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: | Task type | Load |
- **TDD**: always write tests first — load `tdd` skill |-----------|------|
- **Code Review**: load `code-review` skill before any review | Any feature or bug fix | `tdd` |
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work | Refactor or design | `clean-code` or `solid` |
- **Problem first**: load `problem-analysis` skill before coding non-trivial features | Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
+11
View File
@@ -1,5 +1,16 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
# Fetch internal gitea-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://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
ENV GOPRIVATE=gitea.d-ma.be
ENV GOPROXY=direct
ENV GOSUMDB=off
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
+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)
})
}
}
+21 -60
View File
@@ -2,10 +2,12 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "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/auth"
@@ -27,82 +29,41 @@ func main() {
ctx := context.Background() ctx := context.Background()
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
if err != nil { if jwtInitErr != nil {
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err) logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
} }
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken) giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners) ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New() reg := registry.New()
reg.Register(tools.NewRepoList(giteaClient, ownerAllow)) tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
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.NewIssueList(giteaClient, ownerAllow))
reg.Register(tools.NewIssueClose(giteaClient, ownerAllow))
reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow))
reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow))
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
mcpSrv := mcp.NewServer(mcp.ServerOptions{ mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg, Registry: reg,
Sessions: mcp.NewSessionStore(), 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 := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
auth.BearerMiddleware(jwtValidator, cfg.StaticToken, chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(mcpSrv), auth.CallerMiddleware(mcpSrv),
), ),
)) ))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
w.WriteHeader(http.StatusOK) if cfg.DexIssuerURL != "" {
_, _ = w.Write([]byte("ok")) mux.HandleFunc("GET /.well-known/oauth-protected-resource",
}) chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
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)
})
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0") logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
+2 -1
View File
@@ -3,8 +3,8 @@ module gitea.d-ma.be/mathias/gitea-mcp
go 1.26.2 go 1.26.2
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.2.0
github.com/hashicorp/golang-lru/v2 v2.0.7 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 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/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // 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/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.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.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
git.d-ma.be/mathias/mcp-chassis v0.2.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-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)
}
-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
}
+19
View File
@@ -3,8 +3,10 @@ package gitea
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/hashicorp/golang-lru/v2/expirable" "github.com/hashicorp/golang-lru/v2/expirable"
@@ -40,7 +42,21 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
return repo.DefaultBranch, nil 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) { 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 var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
@@ -107,6 +123,9 @@ type rawResponse struct {
} }
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) { 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 var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
+54
View File
@@ -0,0 +1,54 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.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)
}
+65 -13
View File
@@ -9,16 +9,16 @@ import (
) )
type Issue struct { type Issue struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
State string `json:"state"` State string `json:"state"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Labels []Label `json:"labels"` Labels []Label `json:"labels"`
Assignees []User `json:"assignees"` Assignees []User `json:"assignees"`
Comments int `json:"comments"` Comments int `json:"comments"`
} }
type Label struct { type Label struct {
@@ -140,10 +140,62 @@ func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number i
return &iss, nil 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 { type IssueComment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Body string `json:"body"` Body string `json:"body"`
HTMLURL string `json:"html_url"` 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 // CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
+138
View File
@@ -76,6 +76,96 @@ func TestGetIssue_NotFound(t *testing.T) {
assert.ErrorIs(t, err, gitea.ErrNotFound) 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) { func TestCreateIssueComment(t *testing.T) {
var captured []byte var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL) 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)
}
+49
View File
@@ -0,0 +1,49 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// #36: every caller (claude.ai connector, gitea's own convention) sends the
// repo identifier as `repo` and issue/PR index as `index`, but the tools
// declare them as `name` and `number`. Unmatched fields zero-valued the path
// segment and produced gitea's misleading /api/swagger 404. parseArgs now
// aliases repo->name and index->number so the idiomatic call works.
func TestRepoAndIndexAliasesResolve(t *testing.T) {
tests := []struct {
name string
args string
wantPath string
}{
{"repo+index aliases", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"canonical name+number", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"explicit name wins over repo", `{"owner":"mathias","name":"infra","repo":"ignored","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`)
})
}
}
+185 -28
View File
@@ -2,10 +2,13 @@ package tools
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strings"
"time"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "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/gitea"
@@ -14,22 +17,22 @@ import (
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`) 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 { func substitutions(owner, name string) map[string]string {
return map[string]string{ return map[string]string{
"__PROJECT_NAME__": name, "__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. // CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct { type CreateProjectFromTemplate struct {
c *gitea.Client c *gitea.Client
@@ -45,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "create_project_from_template", 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 how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
@@ -53,7 +56,8 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"}, "description":{"type":"string"},
"private":{"type":"boolean"}, "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."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."}
}, },
"required":["owner","name"] "required":["owner","name"]
}`), }`),
@@ -61,13 +65,20 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
} }
type createProjectArgs struct { type createProjectArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Private bool `json:"private"` Private bool `json:"private"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
DispatchAllow bool `json:"dispatch_allow"`
} }
// 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 { type createProjectResult struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
@@ -135,21 +146,167 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
DefaultBranch: newRepo.DefaultBranch, DefaultBranch: newRepo.DefaultBranch,
} }
// Substitute placeholders in known files (best-effort). // The /generate response often omits default_branch — resolve it explicitly,
repls := substitutions(args.Owner, args.Name) // otherwise every file read below hits an empty ref and nothing substitutes
// (the silent-null bug: gitea-mcp#42).
branch := newRepo.DefaultBranch branch := newRepo.DefaultBranch
for _, path := range substitutionFiles { if branch == "" {
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil { if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
// Files that don't exist in this template are silently skipped. branch = r.DefaultBranch
if errors.Is(err, gitea.ErrNotFound) { } else {
continue branch = "main"
} }
// Any other error halts the substitution pass with partial_failure recorded. }
result.PartialFailure = fmt.Sprintf("%s: %v", path, err) result.DefaultBranch = branch
// 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.
repls := substitutions(args.Owner, args.Name)
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
if err != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err)
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 break
} }
result.FilesSubstituted = append(result.FilesSubstituted, path) if substituted != "" {
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
}
}
// Opt the new project into headless dispatch if asked: presence of a
// .dispatch-allow file on the default branch marks it dispatch-eligible
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
// loop already stalled — a failed injection then degrades identically.
if args.DispatchAllow && result.PartialFailure == "" {
const dispatchAllowPath = ".dispatch-allow"
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
}); err != nil {
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
} else {
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
}
}
// If substitution stalled because the generated branch wasn't writable in time,
// the repo IS created — say so clearly and point to the local finalize step,
// 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 = 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). Finalize locally with `hyperguild new-project` "+
"(clone + substitute, no API race). Underlying: %s",
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
// substituted (with no explicit failure) means the walk found no placeholders —
// suspicious for a real template. Surface it instead of returning silent success.
if 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) return textOK(result)
} }
// 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 and we defer substitution with clear guidance rather than hang.
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 {
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
}); err != nil {
return "", fmt.Sprintf("write %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
Branch: branch,
Sha: fc.Sha,
Message: fmt.Sprintf("template: drop placeholder path %s", path),
}); err != nil {
return "", fmt.Sprintf("delete %s: %v", path, err)
}
return path + " -> " + newPath, ""
}
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, ""
}
@@ -5,9 +5,11 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
@@ -17,306 +19,294 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// substitutionFileList matches the tool's internal list — used to drive fake server routing. func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
var substitutionFileList = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
// contentWithPlaceholder is a template file body that contains the placeholder. func templateRepoJSON(name string, isTemplate bool) string {
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n" 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}`,
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}`,
name, name, name, name, isTemplate) name, name, name, name, isTemplate)
} }
// newGeneratedRepoJSON returns the JSON for the newly generated repo. // fakeTemplateServer serves the whole create-from-template flow off an in-memory
func newGeneratedRepoJSON(name string) string { // file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
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}`, type fakeTemplateServer struct {
name, name, name, name) mu sync.Mutex
files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool
puts []string
deletes []string
putBodies map[string]string // path -> decoded written content
repoGetsPost int // GET dest after generate (branch fallback)
} }
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate { func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
c := gitea.NewClient(srvURL, "tok") return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
} }
// TestCreateProjectHappyPath: all 6 files served and substituted. func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
func TestCreateProjectHappyPath(t *testing.T) { return func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
switch { switch {
// Template repo lookup case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": _, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
// Destination repo lookup — 404 means it doesn't exist yet case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": if !f.generated {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`)) _, _ = 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)
// Generate case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": f.generated = true
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) _, _ = 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)
// File contents GET — handle all 6 substitution files case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): var entries []string
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") for path := range f.files {
_, _ = w.Write([]byte(fileContentsJSON(filePath))) 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, ","))
// File contents PUT — handle all 6 substitution files case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/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.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath))) _, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
default: 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) 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"`
}
// 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() defer srv.Close()
tool := newCreateProjectTool(srv.URL) out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
require.NoError(t, err)
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.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) assert.Empty(t, out.PartialFailure)
} }
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the // Fail loud: a template whose files carry no placeholders yields nothing
// server-configured default, so the same binary can generate from template-go-web or // substituted — surface it rather than returning silent success.
// template-go-agent without restart. func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
func TestCreateProjectTemplateNameOverride(t *testing.T) { files := map[string]string{"README.md": "# static, no placeholders\n"}
var templateLookups, generateCalls []string 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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
switch { w.WriteHeader(http.StatusInternalServerError)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent": _, _ = w.Write([]byte(`{"message":"boom"}`))
templateLookups = append(templateLookups, "template-go-agent") return
_, _ = 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)
} }
base(w, r)
})) }))
defer srv.Close() defer srv.Close()
// Server is configured with template-go-web as the default; call overrides to template-go-agent. out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
tool := newCreateProjectTool(srv.URL) assert.NotEmpty(t, out.PartialFailure)
_, err := tool.Call(context.Background(), json.RawMessage( assert.Contains(t, out.PartialFailure, "go.mod")
`{"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")
} }
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network. // dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
func TestCreateProjectNameRegexFailure(t *testing.T) { func TestCreateProject_DispatchAllow(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate( tests := []struct {
gitea.NewClient("http://unused", ""), name string
allowlist.New([]string{"mathias"}), argsJSON string
"mathias", "template-go-web", wantFile bool
) }{
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`)) {"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")
}
})
}
}
// ── 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) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation) assert.ErrorIs(t, err, gitea.ErrValidation)
} }
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error. func TestCreateProject_AllowlistRejects(t *testing.T) {
func TestCreateProjectAllowlistRejects(t *testing.T) { _, err := tools.NewCreateProjectFromTemplate(
tool := tools.NewCreateProjectFromTemplate( gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
gitea.NewClient("http://unused", ""), ).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist") assert.Contains(t, err.Error(), "allowlist")
} }
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template. func TestCreateProject_NotTemplate(t *testing.T) {
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Template lookup returns a non-template repo. if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" { _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
return return
} }
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) }))
defer srv.Close() defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation) assert.ErrorIs(t, err, gitea.ErrValidation)
} }
// TestCreateProjectDestinationExists: destination repo already exists. func TestCreateProject_DestinationExists(t *testing.T) {
func TestCreateProjectDestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch { switch r.URL.Path {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": case "/api/v1/repos/mathias/template-go-agent":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": case "/api/v1/repos/mathias/new-svc":
// Destination exists — return 200. _, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
default: default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
})) }))
defer srv.Close() defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict) 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")
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"context"
"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"
)
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"},
"name":{"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","name","number"]
}`),
}
}
type issueEditArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
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(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.Name, 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"
"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"
"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)
}
+56
View File
@@ -0,0 +1,56 @@
package tools
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"
)
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"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type issueListCommentsArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
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(args.Owner); err != nil {
return nil, err
}
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Name, 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"
"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"
"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)
}
+59
View File
@@ -0,0 +1,59 @@
package tools
import (
"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"
)
// RegisterAll registers every gitea-mcp tool on reg. main.go and the
// dispatch round-trip test share this single list so a newly added tool
// cannot be wired in one place but missing from the other.
//
// giteaBaseURL is needed by workflow_run_trigger; tmplOwner/tmplRepo seed
// create_project_from_template's default source template.
func RegisterAll(
reg *registry.Registry,
c *gitea.Client,
a *allowlist.Allowlist,
giteaBaseURL, tmplOwner, tmplRepo string,
) {
reg.Register(NewRepoList(c, a))
reg.Register(NewRepoGet(c, a))
reg.Register(NewRepoSearch(c, a))
reg.Register(NewRepoStatus(c, a))
reg.Register(NewFileRead(c, a))
reg.Register(NewFileWriteBranch(c, a))
reg.Register(NewFileDelete(c, a))
reg.Register(NewDirList(c, a))
reg.Register(NewBranchList(c, a))
reg.Register(NewBranchDelete(c, a))
reg.Register(NewBranchProtectionGet(c, a))
reg.Register(NewPRCreate(c, a))
reg.Register(NewPRGet(c, a))
reg.Register(NewPRList(c, a))
reg.Register(NewPRMerge(c, a))
reg.Register(NewPRComment(c, a))
reg.Register(NewPRFilesDiff(c, a))
reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL))
reg.Register(NewWorkflowRunStatus(c, a))
reg.Register(NewCodeSearch(c, a))
reg.Register(NewIssueCreate(c, a))
reg.Register(NewIssueEdit(c, a))
reg.Register(NewIssueComment(c, a))
reg.Register(NewCreateProjectFromTemplate(c, a, tmplOwner, tmplRepo))
reg.Register(NewTagCreate(c, a))
reg.Register(NewRepoCreate(c, a))
reg.Register(NewRepoUpdate(c, a))
reg.Register(NewRepoMirrorPush(c, a))
reg.Register(NewRepoTree(c, a))
reg.Register(NewRepoTopicsUpdate(c, a))
reg.Register(NewIssueGet(c, a))
reg.Register(NewIssueList(c, a))
reg.Register(NewIssueListComments(c, a))
reg.Register(NewIssueClose(c, a))
reg.Register(NewIssueReopen(c, a))
reg.Register(NewWorkflowRunList(c, a))
reg.Register(NewReleaseCreate(c, a))
reg.Register(NewRepoDelete(c, a))
}
+58
View File
@@ -0,0 +1,58 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"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/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildRegistry wires the full tool set exactly as main.go does, against an
// unroutable gitea base URL so any handler that reaches the network fails fast
// (connection refused) rather than hanging.
func buildRegistry() *registry.Registry {
reg := registry.New()
c := gitea.NewClient("http://127.0.0.1:1", "")
a := allowlist.New([]string{"mathias"})
tools.RegisterAll(reg, c, a, "http://127.0.0.1:1", "mathias", "template-go-web")
return reg
}
// Every registered tool must be dispatchable and advertise a parseable input
// schema. This is the regression guard for #36's whole class: a tool that is
// wired up but unroutable (or ships a malformed schema) fails CI here instead
// of 404ing a live caller.
func TestEveryRegisteredToolIsDispatchable(t *testing.T) {
reg := buildRegistry()
descs := reg.Tools()
require.NotEmpty(t, descs)
for _, d := range descs {
t.Run(d.Name, func(t *testing.T) {
require.NotEmpty(t, d.Name, "tool has empty name")
assert.True(t, json.Valid(d.InputSchema),
"tool %q ships invalid JSON input schema", d.Name)
// Dispatch with empty args. We do not care whether the call
// succeeds (most fail allowlist/validation/network) — only that
// the name resolves to a handler. ErrToolNotFound here means the
// tool advertised a name the dispatcher cannot route.
_, err := reg.Dispatch(context.Background(), d.Name, json.RawMessage(`{}`))
assert.False(t, errors.Is(err, registry.ErrToolNotFound),
"registered tool %q does not dispatch", d.Name)
})
}
}
// Lock the tool count so an accidental drop of a registration in RegisterAll
// (the single source main.go and this test share) fails loudly.
func TestRegisteredToolCount(t *testing.T) {
assert.Len(t, buildRegistry().Tools(), 38)
}
+49 -1
View File
@@ -2,6 +2,7 @@ package tools
import ( import (
"encoding/json" "encoding/json"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
@@ -17,7 +18,54 @@ func parseArgs(raw json.RawMessage, dst any) error {
if len(raw) == 0 { if len(raw) == 0 {
return json.Unmarshal([]byte("{}"), dst) return json.Unmarshal([]byte("{}"), dst)
} }
return json.Unmarshal(raw, dst) return json.Unmarshal(normalizeAliases(raw), dst)
}
// normalizeAliases maps the near-universal gitea/GitHub argument names onto the
// idiosyncratic ones these tools declare: `repo` -> `name`, `index` -> `number`.
// Every MCP caller (the claude.ai connector, LLMs primed on gitea's own API)
// reaches for `repo`/`index`; without this the unmatched fields zero-valued the
// upstream path segment and produced gitea's misleading /api/swagger 404 (#36).
// The alias key is left intact so a tool whose real field IS `index`
// (e.g. pr_merge) is unaffected.
func normalizeAliases(raw json.RawMessage) json.RawMessage {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return raw // not a JSON object — leave untouched
}
changed := aliasInto(m, "name", "repo")
changed = aliasInto(m, "number", "index") || changed
if !changed {
return raw
}
if patched, err := json.Marshal(m); err == nil {
return patched
}
return raw
}
// aliasInto copies m[alias] into m[canonical] when canonical is absent or an
// empty/zero JSON value and alias carries a usable one. An explicit canonical
// value always wins. Returns true if it wrote canonical.
func aliasInto(m map[string]json.RawMessage, canonical, alias string) bool {
av, ok := m[alias]
if !ok || isEmptyJSON(av) {
return false
}
if cv, ok := m[canonical]; ok && !isEmptyJSON(cv) {
return false
}
m[canonical] = av
return true
}
func isEmptyJSON(v json.RawMessage) bool {
switch strings.TrimSpace(string(v)) {
case "", `""`, "null", "0":
return true
default:
return false
}
} }
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50. // capLimit returns a sane page size: 0 or negative → def, > 50 → 50.