Compare commits

..
5 Commits
Author SHA1 Message Date
mathias 4122e1f2ae fix: merge repo_update — add archived+template, keep default_branch+confirm from main
CD / Lint / Test / Vet (pull_request) Failing after 4s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
2026-05-16 21:17:40 +00:00
mathias d74b196db1 feat(repo_update): tool for archiving + metadata patches
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
Adds a repo_update tool exposing PATCH /api/v1/repos/{owner}/{name}
with optional pointer fields (archived, description, private,
website, template). Only fields set by the caller are sent on the
wire, so the server patches exactly what was asked for.

Originally needed to archive ingestion-svc cleanly instead of
leaving a README tombstone, and to flip template-go-{agent,web}
to template=true so create_project_from_template stops failing
the "is not marked as template" guard.

Wire-level enforcement of "at least one field" returns ErrValidation
before any network call, preventing no-op PATCHes.

private=false (making a repo public) is allowed but flagged in the
tool description with a "verify intent before calling" warning.
The earlier issue draft suggested an ntfy confirmation hook for
that path — out of scope for this PR; the warning string is the
minimum that fits inside the tool surface today.

Wires NewRepoUpdate into cmd/gitea-mcp/main.go alongside the rest
of the repo_* family.

Closes #12
2026-05-16 23:01:33 +02:00
mathias 103194a11a fix(create_project_from_template): accept per-call template_name override
The template name was hardcoded into the binary at startup via
NewCreateProjectFromTemplate("mathias", "template-go-web"), so
generating from a different template (e.g. template-go-agent)
required a code change and restart. The constructor already
parameterised it correctly — the gap was at the tool's input
schema, which never exposed template_name to the caller.

Adds an optional template_name input field. When set, it overrides
the server-configured default for that call only; when omitted,
behavior is unchanged. Template owner stays server-configured —
only the repo name is per-call.

Server-side validation already verifies the resolved template
exists and is marked as a template repo, so no enum constraint
is added — keeps the door open for future templates (go-ml,
go-service, ...) without redeploys.

Adds TestCreateProjectTemplateNameOverride verifying the override
directs both the template lookup and the /generate POST.

Closes #24
2026-05-16 23:01:28 +02:00
mathias 4c87856aec fix(pr_files_diff): copy per-file diff bytes to break buffer aliasing
splitUnifiedDiff used bytes.Buffer to accumulate each file's diff,
then stored buf.Bytes() into the result map and called buf.Reset()
to start the next file. bytes.Buffer.Bytes() returns the buffer's
internal backing slice; Reset() resets length to 0 but reuses the
same backing array. As a result, every map entry aliased the same
storage, so all files ended up showing the LAST file's diff content.

Fix: copy the bytes into a fresh slice before storing in the map.

Adds TestPRFilesDiffPerFileIsolation as a regression test that
asserts each file entry contains its OWN diff --git header and
none of the other files' headers. Verified failing on the prior
code, passing after the fix.

Closes #25
2026-05-16 23:01:18 +02:00
mathiasandClaude Opus 4.7 b4176c1dec chore: re-sync context adapters with upstream root
Derived adapters drifted from canonical root .context/AGENT.md after
the pgvector default change landed upstream. Pure regeneration via
scripts/context-sync.sh, no manual edits. Required to make task check
pass before the feature commits on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:00:23 +02:00
146 changed files with 1262 additions and 7393 deletions
+22 -158
View File
@@ -27,14 +27,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -44,34 +36,6 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -85,7 +49,6 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -95,32 +58,9 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## 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.
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure
@@ -160,64 +100,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
## Knowledge base
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
When available, agents can query the shared knowledge base:
### When to query (treat as a reflex)
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
@@ -263,17 +157,15 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
**Skill trigger table — load before starting, not after getting stuck:**
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
@@ -288,7 +180,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -316,11 +208,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -358,29 +247,4 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
+4 -32
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -37,11 +37,8 @@
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -79,29 +76,4 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
+22 -158
View File
@@ -32,14 +32,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -49,34 +41,6 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -90,7 +54,6 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -100,32 +63,9 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## 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.
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure
@@ -165,64 +105,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
## Knowledge base
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
When available, agents can query the shared knowledge base:
### When to query (treat as a reflex)
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
@@ -268,17 +162,15 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
**Skill trigger table — load before starting, not after getting stuck:**
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
@@ -293,7 +185,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -321,11 +213,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -363,31 +252,6 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
---
+22 -158
View File
@@ -30,14 +30,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -47,34 +39,6 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -88,7 +52,6 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -98,32 +61,9 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## 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.
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure
@@ -163,64 +103,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
## Knowledge base
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
When available, agents can query the shared knowledge base:
### When to query (treat as a reflex)
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
@@ -266,17 +160,15 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
**Skill trigger table — load before starting, not after getting stuck:**
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
@@ -291,7 +183,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -319,11 +211,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -361,29 +250,4 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
+4 -9
View File
@@ -1,13 +1,11 @@
name: CD
"on":
on:
push:
branches: [main]
tags: ["v*"]
# Docs-only pushes don't change the image — skip the build/deploy roll.
# (Only applies to branch pushes; tag pushes always trigger.)
paths-ignore:
- 'docs/**'
pull_request:
branches: [main]
env:
IMAGE: gitea-mcp
@@ -45,6 +43,7 @@ jobs:
name: Build & Import
needs: check
runs-on: self-hosted
if: github.event_name != 'pull_request'
outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }}
steps:
@@ -64,11 +63,7 @@ jobs:
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
# Stamp the real build version: the git tag on a v* build, else the short sha.
VERSION="${{ steps.meta.outputs.version-tag }}"
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "→ Running task check before push..."
task check
echo "✓ pre-push check passed"
+22 -158
View File
@@ -27,14 +27,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -44,34 +36,6 @@ These rules apply to every task across every project, regardless of harness.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
@@ -85,7 +49,6 @@ These rules apply to every task across every project, regardless of harness.
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
@@ -95,32 +58,9 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## 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.
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Infrastructure
@@ -160,64 +100,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
## Knowledge base
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
When available, agents can query the shared knowledge base:
### When to query (treat as a reflex)
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
@@ -263,17 +157,15 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
**Skill trigger table — load before starting, not after getting stuck:**
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
@@ -288,7 +180,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -316,11 +208,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -358,29 +247,4 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
+4 -32
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
@@ -37,11 +37,8 @@
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
@@ -79,29 +76,4 @@ When acting as a coding agent on this project:
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
+1 -15
View File
@@ -1,23 +1,9 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
# Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
# http:// in its go-import meta tag, so rewrite to https here and bypass
# the module proxy + sumdb.
RUN apk add --no-cache git && \
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
ENV GOPRIVATE=git.d-ma.be
ENV GOPROXY=direct
ENV GOSUMDB=off
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
# "dev" for a plain `docker build` (#47).
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gitea-mcp /gitea-mcp
-11
View File
@@ -2,14 +2,3 @@
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
## Quickstart
```bash
task setup:hooks # installs .githooks/pre-push — runs task check before every push
task check # context sync + lint + test + vet
task build # produces bin/gitea-mcp
```
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
hook enforces the quality gate locally; CI re-runs `task check` on every push.
-7
View File
@@ -47,13 +47,6 @@ tasks:
cmds:
- bash scripts/context-sync.sh
setup:hooks:
desc: Install git hooks (.githooks/pre-push)
cmds:
- git config core.hooksPath .githooks
- chmod +x .githooks/pre-push
- echo "✓ git hooks installed (pre-push runs task check)"
context:sync:claude:
cmds: [bash scripts/context-sync.sh claude]
context:sync:agents:
-46
View File
@@ -1,46 +0,0 @@
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
@@ -1,60 +0,0 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthzHandler(t *testing.T) {
tests := []struct {
name string
dexConfigured bool
validatorReady bool
initErr error
wantStatus string
wantLastError string
}{
{
name: "disabled when DEX_ISSUER_URL unset",
wantStatus: "disabled",
wantLastError: "",
},
{
name: "enabled when validator initialized",
dexConfigured: true,
validatorReady: true,
wantStatus: "enabled",
wantLastError: "",
},
{
name: "degraded when Dex configured but init failed",
dexConfigured: true,
initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"),
wantStatus: "degraded",
wantLastError: "fetch oidc discovery: dial tcp: connection refused",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var got healthStatus
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.True(t, got.OK)
assert.Equal(t, tc.wantStatus, got.JWT.Status)
assert.Equal(t, tc.wantLastError, got.JWT.LastError)
})
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"strings"
"time"
)
const (
saCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
)
// k8sBearerRT adds this pod's ServiceAccount bearer to every request. k3s serves
// its OIDC discovery/JWKS over the cluster CA and requires an AUTHENTICATED
// request (anonymous is 401), so the JWKS fetch must carry a token — ADR-0011.
type k8sBearerRT struct {
token string
base http.RoundTripper
}
func (b k8sBearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+b.token)
return b.base.RoundTrip(r)
}
// k8sOIDCClient builds an HTTP client that can reach the in-cluster k8s OIDC
// discovery + JWKS: it trusts the cluster CA and carries this pod's SA bearer.
// Returns an error (not running in a pod, files unreadable) so the caller can
// skip the k8s issuer without disabling other auth.
func k8sOIDCClient() (*http.Client, error) {
ca, err := os.ReadFile(saCAFile)
if err != nil {
return nil, fmt.Errorf("read cluster CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("parse cluster CA: no certs in %s", saCAFile)
}
tok, err := os.ReadFile(saTokenFile)
if err != nil {
return nil, fmt.Errorf("read SA token: %w", err)
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: k8sBearerRT{
token: strings.TrimSpace(string(tok)),
base: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
},
}, nil
}
+57 -68
View File
@@ -2,32 +2,22 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/config"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
)
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
// "0.1.0" — it now tells the truth instead of a stale literal).
var version = "dev"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
// the same structured handler as the rest of the server.
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
@@ -37,75 +27,74 @@ func main() {
ctx := context.Background()
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
// can't be built (not in a pod) or its discovery is unreachable at startup,
// it is dropped so existing Authentik/static auth is never taken down.
var issuers []chassisauth.IssuerConfig
if cfg.DexIssuerURL != "" {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
}
if cfg.K8sIssuerURL != "" {
if client, cerr := k8sOIDCClient(); cerr != nil {
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
} else {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
}
}
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
// startup. Don't let it take down Authentik JWT auth — retry without it.
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
for _, ic := range issuers {
if ic.IssuerURL != cfg.K8sIssuerURL {
pub = append(pub, ic)
}
}
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
}
if jwtInitErr != nil {
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
if err != nil {
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
}
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New()
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
reg.Register(tools.NewRepoUpdate(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))
mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg,
Sessions: mcp.NewSessionStore(),
})
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
// when both MCPResourceURL and a Dex issuer are wired; empty disables
// the challenge so static-only clients aren't pushed into OAuth discovery.
var resourceMetadataURL string
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
}
mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
auth.PassthroughMiddleware(giteaClient, mcpSrv,
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
),
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
auth.CallerMiddleware(mcpSrv),
),
))
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
if cfg.DexIssuerURL != "" {
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
payload := map[string]any{
"resource": cfg.MCPResourceURL,
"authorization_servers": []string{},
}
if cfg.DexIssuerURL != "" {
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
}
_ = json.NewEncoder(w).Encode(payload)
})
addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
-200
View File
@@ -1,200 +0,0 @@
# How gitea-mcp works
`gitea-mcp` is a custom **MCP (Model Context Protocol) front door for Gitea** — a
small Go HTTP service that exposes ~60 Gitea operations as MCP tools over
Streamable HTTP, so a claude.ai connector (or any MCP client) can drive the
homelab's Gitea (`https://git.d-ma.be`) with proper auth, an owner allowlist,
caller attribution, and defensive input handling.
It is **not** a generic Gitea proxy. It is an opinionated, allowlisted, single-
tenant front door built on the shared **`mcp-chassis`** auth library.
---
## 1. Request lifecycle
Everything is wired in `cmd/gitea-mcp/main.go` on a plain `http.ServeMux`:
```
POST /mcp
→ OriginAllowlist (internal/mcp/origin.go) browser Origin gate
→ BearerMiddleware (mcp-chassis auth) authN: static token OR JWT
→ CallerMiddleware (internal/auth/caller.go) extract caller identity
→ MCP server (internal/mcp/server.go) JSON-RPC dispatch → tools
GET /healthz unauthenticated health JSON
GET /.well-known/oauth-protected-resource RFC 9728 metadata (when Dex set)
```
The middleware order is deliberate: Origin first (cheap reject), then authN,
then identity extraction, then the MCP handler. A tool call is a JSON-RPC
`tools/call` that the registry dispatches to a `Tool` handler.
---
## 2. Authentication (`mcp-chassis` `BearerMiddleware`)
`Authorization: Bearer <token>` is checked with this precedence (`auth/bearer.go`
in `mcp-chassis`):
1. **Static bearer** — constant-time compare against `GITEA_MCP_STATIC_TOKEN`.
Wins immediately, never emits a `WWW-Authenticate` challenge. This is the
service-to-service path (repo `.mcp.json` files carrying `GITEA_MCP_TOKEN`).
2. **JWT validation** — against a **list of trusted OIDC issuers**
(`NewMultiJWTValidator`, ADR-0011):
- **Authentik** (`DEX_ISSUER_URL` + `MCP_AUDIENCE=claude-ai`) — the claude.ai
web connector after its OAuth handshake.
- **k3s cluster OIDC** (`K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp`) — lets
in-cluster pods authenticate with audience-bound projected **ServiceAccount
tokens** instead of a static bearer. The k8s issuer is additive and
best-effort: if the in-cluster OIDC can't be reached at startup it is
dropped so Authentik auth is never taken down (see `cmd/gitea-mcp/main.go`
and `cmd/gitea-mcp/k8soidc.go`).
3. Otherwise **401**. If `MCP_RESOURCE_URL` + `DEX_ISSUER_URL` are set, the 401
carries a `WWW-Authenticate: … resource_metadata=…` header (RFC 9728) so
claude.ai's OAuth discovery can find `/.well-known/oauth-protected-resource`.
A JWKS/issuer outage yields **503** (`ErrUnavailable`), distinct from a
present-but-invalid token's **401**, so a transient IdP blip is retried rather
than treated as a hard auth failure.
### The k8s SA-token wrinkle (ADR-0011)
k3s serves its OIDC discovery/JWKS over the **cluster CA** and requires an
**authenticated** request (anonymous → 401). So `k8sOIDCClient()` builds an HTTP
client that trusts `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` and
carries the pod's own SA bearer, passed to the chassis via
`IssuerConfig.HTTPClient`. Proven live: a real `aud=gitea-mcp` SA token → the
running server → HTTP 200.
### Origin allowlist
`OriginAllowlist` (`internal/mcp/origin.go`) rejects any request whose `Origin`
header is not in `GITEA_MCP_ORIGIN_ALLOWLIST` (`https://claude.ai`,
`https://api.anthropic.com`). An **empty** Origin (server-side callers) is
allowed — Origin is a browser-only header.
---
## 3. Authorization, identity & attribution
- **Owner allowlist** (`internal/allowlist`) — tools only operate on owners in
`GITEA_MCP_ALLOWED_OWNERS` (default `mathias`). A call for any other owner is
rejected before it reaches Gitea.
- **Caller identity** (`internal/auth/caller.go`) — the authenticated username is
read from reverse-proxy identity headers, `X-Auth-Request-User` (the verified
OIDC identity, authoritative) preferred over `X-Forwarded-User`. Conflicts are
logged, not silently resolved.
- **Identity footer** (`internal/identity/footer.go`) — mutating tools that write
a body (issue/PR comments, creates) append
`_Created via git-mcp on behalf of @<caller>_`, so actions taken through the
front door are attributable even though all upstream calls use one service PAT.
---
## 4. Upstream Gitea access
`internal/gitea/Client` is a thin REST client over `GITEA_BASE_URL`
(`https://git.d-ma.be`). Every upstream call carries a **single service PAT**
`GITEA_MCP_DEFAULT_TOKEN` — as `Authorization: token <PAT>` (`client.go`). So
gitea-mcp is a *front door*, not a credential pass-through: the caller's identity
is captured for attribution, but Gitea sees one service account. A 60s branch
cache and a 30s HTTP timeout round it out.
Defensive guard: paths with an empty owner/repo segment (`//`) are rejected
locally (`hasEmptySegment`) so callers get a typed validation error instead of
Gitea's opaque `/api/swagger` 404.
---
## 5. Tools
Tools are registered in `internal/tools/registry.go` into a `registry.Registry`;
each implements the `Tool` interface (name, JSON schema, handler). ~60 tools,
by area:
| Area | Tools |
|------|-------|
| Repos | list, get, search, status, create, delete, update, tree, topics_update, mirror_push |
| Files | file_read, file_write_branch, file_delete, dir_list |
| Branches | branch_list, branch_delete, branch_protection_get |
| Issues | list, get, create, edit, close, reopen, comment, label, list_comments |
| PRs | list, get, create, comment, merge, files_diff |
| Labels / Releases / Tags | label_list, release_create, tag_create |
| Workflows (Actions) | run_list, run_status, run_trigger |
| Search / Templates | code_search, create_project_from_template |
| Composite | tbd_ship (trunk-based ship helper) |
Shared tool-layer hygiene (`internal/tools/tool.go`):
- **Alias normalization** — `repo``name` and `number``index` are reconciled so
a tool works whichever spelling a caller sends (an explicit canonical wins).
- **Required-identifier validation** — an empty `repo`/`name` is rejected at the
tool layer (avoids the bare-404 leak); `owner` is covered by the allowlist.
- **Page-size cap** — limits are clamped to 50.
---
## 6. Configuration (env)
| Var | Purpose | Example |
|-----|---------|---------|
| `GITEA_MCP_PORT` | listen port | `8080` |
| `GITEA_BASE_URL` | upstream Gitea | `https://git.d-ma.be` |
| `GITEA_MCP_DEFAULT_TOKEN` | **upstream** service PAT (all Gitea calls) | *(secret)* |
| `GITEA_MCP_STATIC_TOKEN` | static bearer for service-to-service auth | *(secret)* |
| `GITEA_MCP_ALLOWED_OWNERS` | owner allowlist | `mathias` |
| `GITEA_MCP_ORIGIN_ALLOWLIST` | permitted browser Origins | `https://claude.ai,…` |
| `DEX_ISSUER_URL` / `MCP_AUDIENCE` | Authentik issuer + audience | `…/o/claude-ai/`, `claude-ai` |
| `K8S_ISSUER_URL` / `K8S_MCP_AUDIENCE` | k8s OIDC issuer + audience (SA tokens) | `https://kubernetes.default.svc.cluster.local`, `gitea-mcp` |
| `MCP_RESOURCE_URL` | this server's public URL for `.well-known` metadata | `https://git-mcp.d-ma.be` |
Secrets come from k8s Secrets (`gitea-mcp-secrets`, `gitea-mcp-static-token`);
non-secret values are inlined in the Deployment.
---
## 7. Health & observability
- `GET /healthz``{"ok":true,"jwt":{"status":"disabled|enabled|degraded","last_error":"…"}}`.
`degraded` = a JWT issuer was configured but init failed (static-token auth
still works) — distinguishes "IdP unreachable" from "JWT not configured".
- Every auth rejection is audit-logged (`mcp-chassis` `deny`) with the reason,
client IP, token *type*, and a truncated SHA-256 **fingerprint** — never the
raw token, so nothing secret lands in logs.
---
## 8. Deployment & CI/CD
- **Runs in** the `gitea-mcp` k3s namespace (`git.d-ma.be/mathias/infra`
`k3s/apps/gitea-mcp/`): a Deployment on `localhost:5000/gitea-mcp:<sha>`, an
ExternalSecret for tokens, and a **default-deny NetworkPolicy** + an
`allow-ingress-nginx` rule (this namespace is the P6.1 netpol pilot — the only
ingress allowed is from `ingress-nginx`).
- **Public endpoint** `https://git-mcp.d-ma.be` (NPM → ingress-nginx → svc:8080).
- **Pipeline** (`.gitea/workflows/cd.yml`, self-hosted koala runner): quality gate
(test/vet) → `buildah` image → `localhost:5000` → smoke test → the deploy job
clones infra over SSH, `sed`s the image tag in `k3s/apps/gitea-mcp/deployment.yaml`,
commits, and triggers a **Flux** reconcile. Push to `main` ⇒ new image ⇒ rolled.
---
## 9. Operational notes
- **Rollback** = revert the image tag (or the env change) in the infra Deployment;
Flux rolls back. Auth changes are additive, so enabling/disabling the k8s issuer
never affects the Authentik/static paths.
- **NetworkPolicy kill switch** — delete the `gitea-mcp` NetworkPolicies to
restore open ingress if the pilot ever locks something out.
- **claude.ai connector flakiness** — the claude.ai→gitea-mcp MCP path
intermittently hits a Cloudflare "you have been blocked" page (the error names
`anthropic.com`). On the Claude Code CLI, prefer the direct Gitea REST API for
reliability; the MCP connector remains the claude.ai-web path. (brain:
`prefer-rest-api-skills-over-mcp-on-cli-cloudflare-waf`.)
## References
- Auth library: `git.d-ma.be/mathias/mcp-chassis` (`auth` package) — shared
`BearerMiddleware` + multi-issuer `JWTValidator` + RFC 9728 handler.
- Multi-issuer / SA-token design: infra `docs/decisions/ADR-0011-service-to-service-auth.md`.
- Source map: `cmd/gitea-mcp/{main,healthz,k8soidc}.go`,
`internal/{mcp,auth,gitea,tools,allowlist,identity,registry,config}`.
+2 -3
View File
@@ -1,10 +1,10 @@
module git.d-ma.be/mathias/gitea-mcp
module gitea.d-ma.be/mathias/gitea-mcp
go 1.26.2
require (
git.d-ma.be/mathias/mcp-chassis v0.5.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/stretchr/testify v1.11.1
)
@@ -16,7 +16,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
-2
View File
@@ -1,5 +1,3 @@
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+2 -14
View File
@@ -1,11 +1,6 @@
package allowlist
import (
"context"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
import "fmt"
type Allowlist struct {
owners map[string]struct{}
@@ -19,17 +14,10 @@ func New(owners []string) *Allowlist {
return &Allowlist{owners: m}
}
// Check gates owner access to the static list — except for a caller
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
// access Gitea's own permission model already gates more precisely than a
// coarse owner name list ever could.
func (a *Allowlist) Check(ctx context.Context, owner string) error {
func (a *Allowlist) Check(owner string) error {
if owner == "" {
return fmt.Errorf("owner required")
}
if _, ok := gitea.TokenFromContext(ctx); ok {
return nil
}
if _, ok := a.owners[owner]; !ok {
return fmt.Errorf("owner %q not in allowlist", owner)
}
+5 -25
View File
@@ -1,36 +1,16 @@
package allowlist_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"github.com/stretchr/testify/assert"
)
func TestAllowlistCheck(t *testing.T) {
a := allowlist.New([]string{"mathias", "acme"})
ctx := context.Background()
assert.NoError(t, a.Check(ctx, "mathias"))
assert.NoError(t, a.Check(ctx, "acme"))
assert.Error(t, a.Check(ctx, "evil"))
assert.Error(t, a.Check(ctx, ""))
}
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
// is gated by Gitea's own permission model, not the MCP's static owner list —
// otherwise a legitimate second user could never touch their own repos.
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.NoError(t, a.Check(ctx, "someone-else"))
}
// Empty owner is a structural input error, not an authz question — still
// rejected even on the pass-through path.
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.Error(t, a.Check(ctx, ""))
assert.NoError(t, a.Check("mathias"))
assert.NoError(t, a.Check("acme"))
assert.Error(t, a.Check("evil"))
assert.Error(t, a.Check(""))
}
+42
View File
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,92 @@
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)
}
+5 -31
View File
@@ -2,48 +2,22 @@ package auth
import (
"context"
"log/slog"
"net/http"
)
type ctxKey struct{}
// CallerMiddleware extracts the authenticated username from the reverse-proxy
// identity headers and stashes it in the request context for Caller().
//
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
// convention some setups populate instead; it is used only as a fallback when
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
// verified X-Auth-Request-User still wins and we log a warning so the
// misconfiguration is visible rather than silently resolved (#10).
//
// logger may be nil, in which case the conflict warning is skipped.
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
func CallerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authUser := r.Header.Get("X-Auth-Request-User")
fwdUser := r.Header.Get("X-Forwarded-User")
user := authUser
user := r.Header.Get("X-Auth-Request-User")
if user == "" {
user = fwdUser
user = r.Header.Get("X-Forwarded-User")
}
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
"x_auth_request_user", authUser,
"x_forwarded_user", fwdUser)
}
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func withCaller(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, ctxKey{}, user)
}
func Caller(ctx context.Context) string {
if v, ok := ctx.Value(ctxKey{}).(string); ok {
return v
+10 -64
View File
@@ -1,80 +1,26 @@
package auth_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"github.com/stretchr/testify/assert"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
}
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
// absent.
func TestCallerHeaderPrecedence(t *testing.T) {
tests := []struct {
name string
authReq string
forwarded string
wantCaller string
}{
{"auth-request only", "mathiasbq", "", "mathiasbq"},
{"forwarded fallback", "", "fwduser", "fwduser"},
{"both present, same", "same", "same", "same"},
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
{"neither", "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var got string
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = auth.Caller(r.Context())
func TestCallerFromContext(t *testing.T) {
called := false
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
called = true
assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.authReq != "" {
req.Header.Set("X-Auth-Request-User", tc.authReq)
}
if tc.forwarded != "" {
req.Header.Set("X-Forwarded-User", tc.forwarded)
}
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, tc.wantCaller, got)
})
}
}
// When both headers are present and disagree, a warning is logged so the proxy
// misconfiguration is visible rather than silent.
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Auth-Request-User", "authuser")
req.Header.Set("X-Forwarded-User", "fwduser")
h.ServeHTTP(httptest.NewRecorder(), req)
logged := buf.String()
assert.Contains(t, logged, "conflicting")
assert.Contains(t, logged, "authuser")
assert.Contains(t, logged, "fwduser")
// No warning when they agree.
buf.Reset()
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
req2.Header.Set("X-Auth-Request-User", "same")
req2.Header.Set("X-Forwarded-User", "same")
h.ServeHTTP(httptest.NewRecorder(), req2)
assert.Empty(t, buf.String(), "no warning expected when headers agree")
req.Header.Set("X-Auth-Request-User", "mathiasbq")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
assert.True(t, called)
}
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
// A nil JWTValidator always returns false — JWT validation is disabled.
type JWTValidator struct {
issuer string
aud string
cache *jwk.Cache
jwksURI string
}
// NewJWTValidator creates a validator by fetching the OIDC discovery document
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
if issuerURL == "" {
return nil, nil
}
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
if err != nil {
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var doc struct {
JWKSURI string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode oidc discovery: %w", err)
}
cache := jwk.NewCache(ctx)
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
return nil, fmt.Errorf("register jwks uri: %w", err)
}
// warm the cache immediately so first request doesn't block
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
return nil, fmt.Errorf("warm jwks cache: %w", err)
}
return &JWTValidator{
issuer: issuerURL,
aud: audience,
cache: cache,
jwksURI: doc.JWKSURI,
}, nil
}
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
if v == nil {
return false
}
keySet, err := v.cache.Get(ctx, v.jwksURI)
if err != nil {
return false
}
opts := []jwt.ParseOption{
jwt.WithKeySet(keySet),
jwt.WithIssuer(v.issuer),
jwt.WithValidate(true),
}
if v.aud != "" {
opts = append(opts, jwt.WithAudience(v.aud))
}
_, err = jwt.Parse([]byte(rawToken), opts...)
return err == nil
}
-36
View File
@@ -1,36 +0,0 @@
package auth
import (
"context"
"net/http"
"strings"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// TokenValidator asks the upstream service who a bearer token belongs to.
type TokenValidator interface {
ValidateToken(ctx context.Context, token string) (username string, ok bool)
}
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
// if the request's bearer token validates directly against Gitea, it's used
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
// the server's shared default token. Any other bearer (static token, JWT, or
// none) falls through to fallback unchanged — this only adds a capability, it
// never removes the existing auth paths.
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
if hasBearer && token != "" {
if login, ok := validator.ValidateToken(r.Context(), token); ok {
ctx := withCaller(r.Context(), login)
ctx = gitea.WithToken(ctx, token)
onValid.ServeHTTP(w, r.WithContext(ctx))
return
}
}
fallback.ServeHTTP(w, r)
})
}
-82
View File
@@ -1,82 +0,0 @@
package auth_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
type fakeValidator struct {
login string
ok bool
calls int
}
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
f.calls++
return f.login, f.ok
}
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
validator := &fakeValidator{login: "alice", ok: true}
var gotCaller string
var gotToken string
var gotOK bool
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotCaller = auth.Caller(r.Context())
gotToken, gotOK = gitea.TokenFromContext(r.Context())
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("fallback should not be called for a valid PAT")
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer alices-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, "alice", gotCaller)
assert.True(t, gotOK)
assert.Equal(t, "alices-pat", gotToken)
}
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
validator := &fakeValidator{ok: false}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called for an invalid token")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
}
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
validator := &fakeValidator{ok: true, login: "alice"}
fallbackCalled := false
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("onValid should not be called with no Authorization header")
})
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
fallbackCalled = true
})
h := auth.PassthroughMiddleware(validator, onValid, fallback)
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
assert.True(t, fallbackCalled)
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
}
-4
View File
@@ -15,8 +15,6 @@ type Config struct {
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
K8sIssuerURL string // K8S_ISSUER_URL, in-cluster OIDC issuer for ServiceAccount-token auth (ADR-0011 D1); empty disables
K8sAudience string // K8S_MCP_AUDIENCE, required audience claim for k8s SA tokens
}
func Load() (Config, error) {
@@ -30,8 +28,6 @@ func Load() (Config, error) {
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
MCPAudience: os.Getenv("MCP_AUDIENCE"),
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
K8sIssuerURL: os.Getenv("K8S_ISSUER_URL"),
K8sAudience: os.Getenv("K8S_MCP_AUDIENCE"),
}
return cfg, nil
}
+1 -1
View File
@@ -3,7 +3,7 @@ package config_test
import (
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/config"
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-58
View File
@@ -3,11 +3,8 @@ package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/hashicorp/golang-lru/v2/expirable"
@@ -20,21 +17,6 @@ type Client struct {
branchCache *expirable.LRU[string, string]
}
type ctxTokenKey struct{}
// WithToken overrides the token used for upstream Gitea calls made with the
// returned context, taking precedence over the Client's configured default
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
func WithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, ctxTokenKey{}, token)
}
// TokenFromContext returns the token set by WithToken, if any.
func TokenFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxTokenKey{}).(string)
return v, ok
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
@@ -44,23 +26,6 @@ func NewClient(baseURL, token string) *Client {
}
}
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
// using that token, not the client's configured default token) and returns
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
if err != nil || status != http.StatusOK {
return "", false
}
var user struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
return "", false
}
return user.Login, true
}
// DefaultBranch returns the default branch for a repo. Cached for 60s.
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
key := owner + "/" + name
@@ -75,21 +40,7 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
return repo.DefaultBranch, nil
}
// hasEmptySegment reports whether the path portion (before any query string)
// contains an empty segment ("//"), which means an owner or repo path
// parameter was empty. Forwarding it upstream yields gitea's opaque
// /api/swagger 404 (#36), so callers reject it locally instead.
func hasEmptySegment(path string) bool {
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
return strings.Contains(path, "//")
}
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
if hasEmptySegment(path) {
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
@@ -99,9 +50,6 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
return nil, 0, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
@@ -159,9 +107,6 @@ type rawResponse struct {
}
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
if hasEmptySegment(path) {
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
@@ -171,9 +116,6 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
return nil, err
}
token := c.token
if override, ok := TokenFromContext(ctx); ok {
token = override
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
-54
View File
@@ -1,54 +0,0 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// #36 second line of defence: when owner or repo is empty the path contains an
// empty segment ("//"). Rather than forward it upstream — where gitea answers
// with its opaque /api/swagger 404 — the client must reject it locally with a
// validation error and never touch the network.
func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
paths := []string{
"/api/v1/repos/mathias//issues", // empty repo
"/api/v1/repos//gitea-mcp/contents/", // empty owner
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
}
for _, p := range paths {
_, _, err := c.GetJSON(context.Background(), p)
require.Error(t, err, "path %q should be rejected", p)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "guard must short-circuit before any HTTP call")
}
// A well-formed path with a query string must still pass the guard.
func TestWellFormedPathPasses(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, status, err := c.GetJSON(context.Background(), "/api/v1/repos/mathias/gitea-mcp/issues?state=open")
require.NoError(t, err)
assert.Equal(t, 200, status)
}
+1 -44
View File
@@ -7,7 +7,7 @@ import (
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -50,49 +50,6 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
}
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
ctx := gitea.WithToken(context.Background(), "caller-token")
_, status, err := c.GetJSON(ctx, "/api/v1/user")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Equal(t, "token caller-token", gotAuth)
}
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "candidate-token")
assert.True(t, ok)
assert.Equal(t, "alice", login)
}
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "default-token")
login, ok := c.ValidateToken(context.Background(), "bad-token")
assert.False(t, ok)
assert.Empty(t, login)
}
func TestRetryOnPostNotRetried(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+15 -135
View File
@@ -1,13 +1,10 @@
package gitea
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"net/url"
)
type CodeSearchHit struct {
@@ -17,147 +14,30 @@ type CodeSearchHit struct {
Score float64 `json:"score,omitempty"`
}
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
// and scan, so one call against a huge repo can't run indefinitely.
const codeSearchMaxFiles = 2000
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
// before any fetch) — almost certainly binary/vendor/generated content, not
// worth the cost of fetching just to reject.
const codeSearchMaxFileSize = 512 * 1024
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
// obviously-binary content by extension, checked against the tree listing.
var codeSearchBinaryExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
".pyc": true, ".o": true, ".a": true,
type codeSearchEnvelope struct {
Data []CodeSearchHit `json:"data"`
OK bool `json:"ok"`
}
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
// has no code-content-search endpoint — confirmed against a live 1.25.5
// instance's swagger spec (only /repos/search, /repos/issues/search,
// /topics/search etc exist). The web UI's OWN code search falls back to
// server-side `git grep` because no Repository Indexer is enabled on that
// instance, and that fallback is an HTML-only route, not JSON API. So this
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
// user-supplied input) — against file contents.
//
// Pagination is over the FULL sorted result set, recomputed on every call —
// there is no server-side index to page through incrementally, so requesting
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
// fix, and is an infra decision, not something gitea-mcp controls.
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
if q == "" {
return nil, fmt.Errorf("q is required: %w", ErrValidation)
}
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
r, err := c.GetRepo(ctx, owner, repo)
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
owner, repo, url.QueryEscape(q), page, limit)
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
return nil, err
}
branch := r.DefaultBranch
if branch == "" {
branch = "main"
if err := MapStatus(status, body); err != nil {
return nil, err
}
tree, err := c.GetTree(ctx, owner, repo, branch, true)
if err != nil {
return nil, fmt.Errorf("tree walk: %w", err)
var env codeSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
qLower := strings.ToLower(q)
all := make([]CodeSearchHit, 0)
scanned := 0
for _, e := range tree.Tree {
if ctx.Err() != nil {
break
}
if e.Type != "blob" {
continue
}
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
continue
}
if e.Size > codeSearchMaxFileSize {
continue
}
if scanned >= codeSearchMaxFiles {
break
}
scanned++
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
if ferr != nil {
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
}
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
if derr != nil {
continue
}
if bytes.IndexByte(decoded, 0) >= 0 {
continue // binary content the extension filter missed
}
content := string(decoded)
contentLower := strings.ToLower(content)
count := strings.Count(contentLower, qLower)
if count == 0 {
continue
}
idx := strings.Index(contentLower, qLower)
all = append(all, CodeSearchHit{
Path: e.Path,
Snippet: codeSearchSnippet(content, idx, len(q)),
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
Score: float64(count),
})
}
sort.Slice(all, func(i, j int) bool {
if all[i].Score != all[j].Score {
return all[i].Score > all[j].Score
}
return all[i].Path < all[j].Path
})
start := (page - 1) * limit
if start >= len(all) {
return []CodeSearchHit{}, nil
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
// codeSearchSnippet returns a short window of text centered on a match,
// trimmed to a single line-ish window so results read like a grep hit rather
// than a content dump.
func codeSearchSnippet(content string, idx, matchLen int) string {
const window = 60
start := idx - window
if start < 0 {
start = 0
}
end := idx + matchLen + window
if end > len(content) {
end = len(content)
}
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
return strings.TrimSpace(snippet)
return env.Data, nil
}
+18 -151
View File
@@ -2,78 +2,31 @@ package gitea_test
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
// UI's own code search falls back to server-side `git grep`, an HTML-only
// route, not JSON API). Records which paths were actually fetched, so tests
// can assert a file was SKIPPED (binary ext, oversized) without ever being
// read, not just absent from results.
type codeSearchFake struct {
files map[string]string // path -> content
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
fetched []string
}
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
func TestSearchCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
var entries []string
for path, content := range f.files {
size := int64(len(content))
if s, ok := f.sizes[path]; ok {
size = s
}
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
f.fetched = append(f.fetched, path)
content, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
}
func TestSearchCode_FindsMatchInTree(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/code_search.go",
"snippet":"func (c *Client) SearchCode",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
"score":2.5
}],
"ok":true
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
@@ -81,92 +34,6 @@ func TestSearchCode_FindsMatchInTree(t *testing.T) {
require.NoError(t, err)
require.Len(t, hits, 1)
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
assert.Contains(t, hits[0].Snippet, "SearchCode")
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
assert.Equal(t, 1.0, hits[0].Score)
}
func TestSearchCode_CaseInsensitive(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"README.md": "# MyProject\n\nBuild instructions here.\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
require.NoError(t, err)
require.Len(t, hits, 1)
}
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"assets/logo.png": "SearchCode", // would match if fetched — must not be
"main.go": "package main\n",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
}
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
f := &codeSearchFake{
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits)
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
}
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
f := &codeSearchFake{files: map[string]string{
"data.bin": "SearchCode\x00binary-marker",
}}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
}
func TestSearchCode_Pagination(t *testing.T) {
files := map[string]string{}
for i := 0; i < 5; i++ {
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
}
f := &codeSearchFake{files: files}
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
require.NoError(t, err)
require.Len(t, page1, 2)
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
require.NoError(t, err)
require.Len(t, page2, 2)
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
}
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
c := gitea.NewClient("http://unused", "tok")
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
-4
View File
@@ -27,10 +27,6 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
if err := MapStatus(status, body); err != nil {
return nil, err
}
// Array response means path is a directory — guide caller to dir_list.
if len(body) > 0 && body[0] == '[' {
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
}
var fc FileContents
if err := json.Unmarshal(body, &fc); err != nil {
return nil, err
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-151
View File
@@ -4,8 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
type Issue struct {
@@ -14,21 +12,6 @@ type Issue struct {
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Labels []Label `json:"labels"`
Assignees []User `json:"assignees"`
Comments int `json:"comments"`
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
}
type User struct {
Login string `json:"login"`
}
type CreateIssueArgs struct {
@@ -39,22 +22,6 @@ type CreateIssueArgs struct {
Milestone int64 `json:"milestone,omitempty"`
}
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
body, status, err := c.GetJSON(ctx, p)
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
}
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args)
@@ -75,128 +42,10 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
return &iss, nil
}
// ListIssuesArgs captures the optional query params for ListIssues.
type ListIssuesArgs struct {
State string // "open" | "closed" | "all"
Labels string // comma-separated label names
Since string // ISO 8601
Page int
Limit int
}
// ListIssues fetches issues for a repo. Pulls are excluded server-side
// (type=issues) so they don't leak through the same endpoint.
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
q := url.Values{}
q.Set("type", "issues")
if args.State != "" {
q.Set("state", args.State)
}
if args.Labels != "" {
q.Set("labels", args.Labels)
}
if args.Since != "" {
q.Set("since", args.Since)
}
if args.Page > 0 {
q.Set("page", strconv.Itoa(args.Page))
}
if args.Limit > 0 {
q.Set("limit", strconv.Itoa(args.Limit))
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var issues []Issue
if err := json.Unmarshal(body, &issues); err != nil {
return nil, err
}
return issues, nil
}
// SetIssueState flips an issue between "open" and "closed" via PATCH.
// Gitea uses the same endpoint for both transitions.
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
payload, err := json.Marshal(map[string]string{"state": state})
if err != nil {
return nil, err
}
body, status, err := c.PatchJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil,
// left untouched) from an explicit empty string (clears the field). Maps to
// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}.
type EditIssueArgs struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
// EditIssue patches an issue's title and/or body. Only fields set in args are
// sent, so omitted fields are left as-is server-side. Body is sent verbatim —
// no identity footer — so repeated edits are idempotent.
func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PatchJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
type IssueComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
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
+1 -170
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -45,127 +45,6 @@ func TestCreateIssue(t *testing.T) {
assert.Equal(t, "open", iss.State)
}
func TestGetIssue(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", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "fix auth", iss.Title)
assert.Equal(t, "open", iss.State)
assert.Equal(t, 3, iss.Comments)
}
func TestGetIssue_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.GetIssue(context.Background(), "o", "r", 999)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestEditIssue(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title, body := "new title", "new body"
iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "new title", payload["title"])
assert.Equal(t, "new body", payload["body"])
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "new title", iss.Title)
}
// EditIssue must send only the fields explicitly provided — an omitted field
// (nil pointer) is left untouched server-side.
func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title := "only title"
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "only title", payload["title"])
_, hasBody := payload["body"]
assert.False(t, hasBody, "body must be omitted when not set")
}
// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted.
func TestEditIssue_EmptyBodyIsSent(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
body := ""
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
val, hasBody := payload["body"]
assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field")
assert.Equal(t, "", val)
}
func TestEditIssue_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
title := "x"
_, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestCreateIssueComment(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -191,51 +70,3 @@ func TestCreateIssueComment(t *testing.T) {
assert.Equal(t, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
}
func TestListIssueComments(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
require.Len(t, comments, 2)
assert.Equal(t, int64(1), comments[0].ID)
assert.Equal(t, "first", comments[0].Body)
assert.Equal(t, "alice", comments[0].User.Login)
assert.Equal(t, "bob", comments[1].User.Login)
}
func TestListIssueComments_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Empty(t, comments)
}
func TestListIssueComments_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
-48
View File
@@ -1,48 +0,0 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
// ListLabels fetches all labels defined on a repo.
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
// space with issues, per Gitea). This is additive per Gitea's own POST
// semantics — existing labels are left in place, no replace/delete needed.
// Returns the issue's full label set after the add.
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
-107
View File
@@ -1,107 +0,0 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListLabels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
require.Len(t, labels, 2)
assert.Equal(t, int64(1), labels[0].ID)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "ee0701", labels[0].Color)
assert.Equal(t, "enhancement", labels[1].Name)
}
func TestListLabels_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
assert.Empty(t, labels)
}
func TestListLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListLabels(context.Background(), "o", "r")
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestAddIssueLabels(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":3,"name":"priority","color":"00ff00"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
require.Len(t, ids, 1)
assert.Equal(t, float64(3), ids[0])
require.Len(t, labels, 2)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "priority", labels[1].Name)
}
func TestAddIssueLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
-71
View File
@@ -1,71 +0,0 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type PushMirror struct {
ID int `json:"id"`
RemoteName string `json:"remote_name"`
RemoteAddress string `json:"remote_address"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
}
type AddPushMirrorArgs struct {
RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username,omitempty"`
RemotePassword string `json:"remote_password,omitempty"`
Interval string `json:"interval,omitempty"`
SyncOnCommit bool `json:"sync_on_commit,omitempty"`
}
func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
resp, status, err := c.PostJSON(ctx, path, body)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var m PushMirror
if err := json.Unmarshal(resp, &m); err != nil {
return nil, err
}
return &m, nil
}
func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
resp, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var mirrors []PushMirror
if err := json.Unmarshal(resp, &mirrors); err != nil {
return nil, err
}
return mirrors, nil
}
func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName)
resp, status, err := c.DeleteJSON(ctx, path)
if err != nil {
return err
}
if status == 204 {
return nil
}
return MapStatus(status, resp)
}
-64
View File
@@ -1,64 +0,0 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddPushMirror(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
RemoteAddress: "https://github.com/mathias/infra.git",
RemoteUsername: "mathias",
RemotePassword: "secret",
Interval: "8h0m0s",
SyncOnCommit: true,
})
require.NoError(t, err)
assert.Equal(t, "mirror-github", m.RemoteName)
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
}
func TestListPushMirrors(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/push_mirrors", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
require.NoError(t, err)
require.Len(t, mirrors, 1)
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
}
func TestDeletePushMirror(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
require.NoError(t, err)
}
-2
View File
@@ -16,12 +16,10 @@ type PullRequest struct {
Draft bool `json:"draft"`
Head struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
Mergeable bool `json:"mergeable"`
}
type CreatePullRequestArgs struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+30 -169
View File
@@ -18,109 +18,6 @@ type Repo struct {
Template bool `json:"template"`
}
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
SHA string `json:"sha"`
Size int64 `json:"size"`
URL string `json:"url"`
}
type Tree struct {
SHA string `json:"sha"`
URL string `json:"url"`
Tree []TreeEntry `json:"tree"`
Truncated bool `json:"truncated"`
}
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
if recursive {
path += "?recursive=1"
}
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var t Tree
if err := json.Unmarshal(body, &t); err != nil {
return nil, err
}
return &t, nil
}
type Release struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
HTMLURL string `json:"html_url"`
CreatedAt string `json:"created_at"`
}
type CreateReleaseArgs struct {
TagName string `json:"tag_name"`
Name string `json:"name,omitempty"`
Body string `json:"body,omitempty"`
Draft bool `json:"draft,omitempty"`
Prerelease bool `json:"prerelease,omitempty"`
// Target branch or commit SHA for tag creation. Empty = repo default branch.
Target string `json:"target_commitish,omitempty"`
}
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner, repo)
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
resp, status, err := c.PostJSON(ctx, path, body)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var r Release
if err := json.Unmarshal(resp, &r); err != nil {
return nil, err
}
return &r, nil
}
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
resp, status, err := c.DeleteJSON(ctx, path)
if err != nil {
return err
}
if status == 204 {
return nil
}
return MapStatus(status, resp)
}
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
body, err := json.Marshal(map[string][]string{"topics": topics})
if err != nil {
return err
}
resp, status, err := c.PutJSON(ctx, path, body)
if err != nil {
return err
}
if status == 204 {
return nil
}
return MapStatus(status, resp)
}
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
if page < 1 {
page = 1
@@ -174,72 +71,6 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
return env.Data, nil
}
type CreateRepoArgs struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private,omitempty"`
AutoInit bool `json:"auto_init,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
// Org, when non-empty, creates the repo under the named organisation.
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
Org string `json:"-"`
}
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
var path string
if args.Org != "" {
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
} else {
path = "/api/v1/user/repos"
}
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
resp, status, err := c.PostJSON(ctx, path, body)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(resp, &r); err != nil {
return nil, err
}
return &r, nil
}
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
type UpdateRepoArgs struct {
Description *string `json:"description,omitempty"`
Private *bool `json:"private,omitempty"`
Website *string `json:"website,omitempty"`
DefaultBranch *string `json:"default_branch,omitempty"`
Archived *bool `json:"archived,omitempty"`
Template *bool `json:"template,omitempty"`
}
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
resp, status, err := c.PatchJSON(ctx, path, body)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(resp, &r); err != nil {
return nil, err
}
return &r, nil
}
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
body, status, err := c.GetJSON(ctx, path)
@@ -256,3 +87,33 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
return &r, nil
}
// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}.
// Pointer fields let the caller omit unset values from the wire payload, so the
// server only patches what was explicitly requested.
type EditRepoArgs struct {
Archived *bool `json:"archived,omitempty"`
Description *string `json:"description,omitempty"`
Private *bool `json:"private,omitempty"`
Website *string `json:"website,omitempty"`
Template *bool `json:"template,omitempty"`
}
func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) {
body, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("marshal edit args: %w", err)
}
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
resp, status, err := c.PatchJSON(ctx, path, body)
if err != nil {
return nil, err
}
if err := MapStatus(status, resp); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(resp, &r); err != nil {
return nil, err
}
return &r, nil
}
+1 -124
View File
@@ -7,7 +7,7 @@ import (
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -47,129 +47,6 @@ func TestListRepos(t *testing.T) {
assert.Equal(t, "main", repos[0].DefaultBranch)
}
func TestCreateRepo_User(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
Name: "infra",
Private: true,
})
require.NoError(t, err)
assert.Equal(t, "mathias/infra", r.FullName)
assert.Equal(t, "main", r.DefaultBranch)
}
func TestCreateRepo_Org(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
Name: "infra",
Org: "hyperguild",
})
require.NoError(t, err)
assert.Equal(t, "hyperguild/infra", r.FullName)
}
func TestUpdateRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
}))
defer srv.Close()
desc := "updated"
c := gitea.NewClient(srv.URL, "tok")
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
Description: &desc,
})
require.NoError(t, err)
assert.Equal(t, "updated", r.Description)
}
func TestGetTree(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
require.NoError(t, err)
assert.Equal(t, "abc", tree.SHA)
require.Len(t, tree.Tree, 2)
assert.Equal(t, "README.md", tree.Tree[0].Path)
assert.Equal(t, "blob", tree.Tree[0].Type)
assert.Equal(t, int64(13), tree.Tree[0].Size)
}
func TestUpdateTopics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
require.NoError(t, err)
}
func TestCreateRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
TagName: "v1.0.0",
Name: "v1.0.0",
Body: "first release",
})
require.NoError(t, err)
assert.Equal(t, "v1.0.0", rel.TagName)
assert.Equal(t, "first release", rel.Body)
}
func TestDeleteRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DeleteRepo(context.Background(), "mathias", "infra")
require.NoError(t, err)
}
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+26 -76
View File
@@ -4,8 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
)
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
@@ -14,45 +14,51 @@ type DispatchWorkflowArgs struct {
Inputs map[string]any `json:"inputs,omitempty"`
}
// DispatchWorkflow triggers a workflow_dispatch event. Gitea returns 204 No
// Content with NO Location header, so the response carries no run ID — callers
// resolve the new run separately via ListWorkflowRuns. Returns nil on success.
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) error {
// WorkflowRunTrigger holds the run ID extracted from the Location header.
type WorkflowRunTrigger struct {
RunID int64
}
// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID.
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
payload, err := json.Marshal(args)
if err != nil {
return err
return nil, err
}
resp, err := c.doRaw(ctx, "POST", p, payload)
if err != nil {
return err
return nil, err
}
if resp.Status != 204 {
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
return mapErr
return nil, mapErr
}
return fmt.Errorf("unexpected status %d", resp.Status)
return nil, fmt.Errorf("unexpected status %d", resp.Status)
}
return nil
location := resp.Headers.Get("Location")
if location == "" {
return nil, fmt.Errorf("missing Location header in dispatch response")
}
// Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment.
parts := strings.Split(strings.TrimRight(location, "/"), "/")
if len(parts) == 0 {
return nil, fmt.Errorf("malformed Location: %s", location)
}
runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return nil, fmt.Errorf("parse run id from %q: %w", location, err)
}
return &WorkflowRunTrigger{RunID: runID}, nil
}
// WorkflowRun represents a Gitea Actions run.
type WorkflowRun struct {
ID int64 `json:"id"`
DisplayTitle string `json:"display_title,omitempty"`
Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
Event string `json:"event,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
HeadBranch string `json:"head_branch,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
RunNumber int64 `json:"run_number,omitempty"`
StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at,omitempty"`
HTMLURL string `json:"html_url"`
Actor struct {
Login string `json:"login"`
} `json:"actor,omitempty"`
}
// GetWorkflowRun fetches the status of a specific Actions run.
@@ -71,59 +77,3 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
}
return &run, nil
}
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
type ListWorkflowRunsArgs struct {
Branch string
HeadSHA string
Status string // queued | in_progress | completed | all
Event string // push | pull_request | schedule | workflow_dispatch | all
Workflow string
Page int
Limit int
}
type workflowRunsResponse struct {
TotalCount int64 `json:"total_count"`
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
}
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
// Status / Event of "all" or "" are treated as no-filter.
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
q := url.Values{}
if args.Branch != "" {
q.Set("branch", args.Branch)
}
if args.HeadSHA != "" {
q.Set("head_sha", args.HeadSHA)
}
if args.Status != "" && args.Status != "all" {
q.Set("status", args.Status)
}
if args.Event != "" && args.Event != "all" {
q.Set("event", args.Event)
}
if args.Workflow != "" {
q.Set("workflow", args.Workflow)
}
if args.Page > 0 {
q.Set("page", strconv.Itoa(args.Page))
}
if args.Limit > 0 {
q.Set("limit", strconv.Itoa(args.Limit))
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var resp workflowRunsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+18 -7
View File
@@ -9,14 +9,11 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Gitea's dispatch endpoint returns 204 No Content with NO Location header.
// Dispatch must succeed and forward ref+inputs in the body; the run ID is
// resolved separately by the tool via ListWorkflowRuns.
func TestDispatchWorkflow(t *testing.T) {
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -25,17 +22,18 @@ func TestDispatchWorkflow(t *testing.T) {
var err error
gotBody, err = io.ReadAll(r.Body)
assert.NoError(t, err)
// No Location header — matches real Gitea.
w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
Ref: "main",
Inputs: map[string]any{"env": "prod"},
})
require.NoError(t, err)
assert.Equal(t, int64(789), result.RunID)
var body map[string]any
require.NoError(t, json.Unmarshal(gotBody, &body))
@@ -45,6 +43,19 @@ func TestDispatchWorkflow(t *testing.T) {
assert.Equal(t, "prod", inputs["env"])
}
func TestDispatchWorkflowMissingLocation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 204 but no Location header
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Location")
}
func TestDispatchWorkflowError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -52,7 +63,7 @@ func TestDispatchWorkflowError404(t *testing.T) {
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound))
}
+1 -1
View File
@@ -3,7 +3,7 @@ package identity_test
import (
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const (
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"sync"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"github.com/stretchr/testify/assert"
)
-74
View File
@@ -1,74 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// #38: `repo` (and `number`) are the canonical, idiomatic gitea/GitHub arg names
// that identifier tools advertise. `name` is kept as a back-compat alias via the
// bidirectional shim, so old `name` callers still work. `index`->`number` stays.
// An explicit canonical (`repo`) always wins over its alias (`name`).
func TestRepoAndIndexAliasesResolve(t *testing.T) {
tests := []struct {
name string
args string
wantPath string
}{
{"canonical repo+number", `{"owner":"mathias","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"repo+index alias", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"legacy name alias", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
{"explicit repo wins over name", `{"owner":"mathias","name":"ignored","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":7,"title":"t"}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.args))
require.NoError(t, err)
assert.Equal(t, tc.wantPath, gotPath)
assert.Contains(t, string(out), `"number":7`)
})
}
}
// #38: identifier tools must ADVERTISE `repo` (not `name`) in their schema, so
// the contract a client reads matches what every caller sends. The two create
// tools keep `name` because there it means "name of the new repo", not an
// existing-repo identifier.
func TestRepoIsCanonicalInAdvertisedSchema(t *testing.T) {
c := gitea.NewClient("http://unused", "")
a := allowlist.New([]string{"mathias"})
identifier := map[string]json.RawMessage{
"repo_get": tools.NewRepoGet(c, a).Descriptor().InputSchema,
"issue_get": tools.NewIssueGet(c, a).Descriptor().InputSchema,
"pr_merge": tools.NewPRMerge(c, a).Descriptor().InputSchema,
"repo_delete": tools.NewRepoDelete(c, a).Descriptor().InputSchema,
"file_read": tools.NewFileRead(c, a).Descriptor().InputSchema,
}
for name, sch := range identifier {
s := string(sch)
assert.Contains(t, s, `"repo":`, name+" must advertise repo")
assert.NotContains(t, s, `"name":`, name+" must not advertise name")
}
// create tools keep `name` (new resource name, not an existing-repo id)
assert.Contains(t, string(tools.NewRepoCreate(c, a).Descriptor().InputSchema), `"name":`)
}
+8 -8
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 BranchDelete struct {
@@ -27,17 +27,17 @@ func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","repo","branch"]
"required":["owner","name","branch"]
}`),
}
}
type branchDeleteArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Branch string `json:"branch"`
}
@@ -46,14 +46,14 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
}
if err := t.c.DeleteBranch(ctx, args.Owner, args.Repo, args.Branch); err != nil {
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 BranchList struct {
@@ -26,18 +26,18 @@ func (t *BranchList) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","repo"]
"required":["owner","name"]
}`),
}
}
type branchListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Page int `json:"page"`
Limit int `json:"limit"`
}
@@ -47,11 +47,11 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
branches, err := t.c.ListBranches(ctx, args.Owner, args.Repo, args.Page, capLimit(args.Limit, 30))
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 BranchProtectionGet struct {
@@ -26,17 +26,17 @@ func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","repo","branch"]
"required":["owner","name","branch"]
}`),
}
}
type branchProtectionGetArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Branch string `json:"branch"`
}
@@ -45,11 +45,11 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, args.Branch)
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+5 -5
View File
@@ -8,9 +8,9 @@ import (
"sync"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 semaphore chan struct{}
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
type codeSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Repo string `json:"repo,omitempty"` // optional: empty => owner-wide fan-out (#37 opt-out)
Repo string `json:"repo"`
Page int `json:"page"`
Limit int `json:"limit"`
}
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
+73 -106
View File
@@ -2,92 +2,36 @@ package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
// for a set of repos — the real endpoints code_search's underlying SearchCode
// now uses (Gitea's REST API has no code-content-search endpoint; see
// internal/gitea/code_search.go's doc comment).
type multiRepoSearchFake struct {
owner string
repos []string // ListRepos response, in this order
files map[string]map[string]string // repo -> path -> content
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
}
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
for _, repo := range f.repos {
base := "/api/v1/repos/" + f.owner + "/" + repo
switch {
case p == base && f.fail[repo]:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
return
case p == base:
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
return
case strings.HasPrefix(p, base+"/git/trees/"):
var entries []string
for path := range f.files[repo] {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
}
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
return
case strings.HasPrefix(p, base+"/contents/"):
path := strings.TrimPrefix(p, base+"/contents/")
content, ok := f.files[repo][path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
enc := base64.StdEncoding.EncodeToString([]byte(content))
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
return
}
}
if p == "/api/v1/users/"+f.owner+"/repos" {
var entries []string
for _, repo := range f.repos {
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
}
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
return
}
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}
func TestCodeSearchSingleRepo(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra"},
files: map[string]map[string]string{
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
},
}
srv := httptest.NewServer(f.handler(t))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/repos.go",
"snippet":"func (c *Client) ListRepos",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
"score":3.0
}],
"ok":true
}`))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -106,7 +50,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
}
func TestCodeSearchAllowlistRejects(t *testing.T) {
@@ -123,15 +67,22 @@ func TestCodeSearchRequiresQ(t *testing.T) {
}
func TestCodeSearchFanOutHappyPath(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "gitea-mcp"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
},
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
case "/api/v1/repos/mathias/gitea-mcp/search":
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
srv := httptest.NewServer(f.handler(t))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -159,15 +110,23 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
}
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"infra", "broken"},
files: map[string]map[string]string{
"infra": {"main.go": "this is an infra hit\n"},
},
fail: map[string]bool{"broken": true},
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/broken/search":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
default:
http.NotFound(w, r)
}
srv := httptest.NewServer(f.handler(t))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
@@ -175,9 +134,7 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
} `json:"results"`
Results []struct{ Repo string `json:"repo"` } `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
}
@@ -190,31 +147,41 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
}
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
f := &multiRepoSearchFake{
owner: "mathias",
repos: []string{"alpha", "beta"},
files: map[string]map[string]string{
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
},
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/alpha/search":
// low score
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/beta/search":
// high score
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
srv := httptest.NewServer(f.handler(t))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 2)
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
// First result must be the high-score one
assert.True(t, result.Results[0].Score > result.Results[1].Score,
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
"expected results sorted by score desc, got %v then %v",
result.Results[0].Score, result.Results[1].Score)
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
}
+32 -364
View File
@@ -2,37 +2,34 @@ package tools
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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"
)
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
var substitutionFiles = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func substitutions(owner, name string) map[string]string {
return map[string]string{
"__PROJECT_NAME__": name,
// 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,
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
}
}
func applyReplacements(s string, repls map[string]string) string {
for k, v := range repls {
s = strings.ReplaceAll(s, k, v)
}
return s
}
// CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct {
c *gitea.Client
@@ -48,7 +45,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "create_project_from_template",
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
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).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
@@ -56,9 +53,7 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"},
"private":{"type":"boolean"},
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file (dispatch#3) AND register owner/name into mathias/dispatch's git-tracked allowlist (dispatch-repos.txt, dispatch#19) — both gates are required for the watcher to actually pick the repo up; each is applied independently and idempotently. Default false. Safe to re-request on resume."},
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
},
"required":["owner","name"]
}`),
@@ -71,16 +66,8 @@ type createProjectArgs struct {
Description string `json:"description"`
Private bool `json:"private"`
TemplateName string `json:"template_name"`
DispatchAllow bool `json:"dispatch_allow"`
Resume bool `json:"resume"`
}
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
// sandbox convention: presence of the file (not its content) marks the repo
// dispatch-eligible; the comment exists only to explain that to a human reader.
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
"# See dispatch#3.\n"
type createProjectResult struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
@@ -88,22 +75,8 @@ type createProjectResult struct {
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// The dispatch allowlist (dispatch#19) is a fixed integration point — a
// specific file in a specific repo the mathias/dispatch watcher reads at the
// start of every cycle. Hardcoded to match its own DISPATCH_ALLOWLIST_OWNER/
// _REPO/_PATH defaults (unconfigured in the live CronJob, confirmed 2026-07-06).
const (
dispatchAllowlistOwner = "mathias"
dispatchAllowlistRepo = "dispatch"
dispatchAllowlistPath = "dispatch-repos.txt"
dispatchAllowlistBranch = "main"
)
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args createProjectArgs
if err := parseArgs(raw, &args); err != nil {
@@ -111,7 +84,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
}
// Allowlist check first.
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
@@ -120,94 +93,6 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
}
var result createProjectResult
var branch string
var err error
if args.Resume {
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
} else {
result, branch, err = t.createDestination(ctx, args)
}
if err != nil {
return nil, err
}
// Substitute across the WHOLE tree: content in every blob, plus a path rename
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
// A fixed known-files list can't rename directories or cover every templated
// file, which is why the old scaffold didn't build. GetTree reflects the
// branch's CURRENT state, which is what makes this loop safe to re-run on
// resume: a file already fixed in a prior partial pass shows up already-correct
// (or already-renamed) and substituteEntry is a no-op for it.
repls := substitutions(args.Owner, args.Name)
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
if terr != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
return textOK(result)
}
for _, e := range tree.Tree {
if e.Type != "blob" {
continue
}
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
if fail != "" {
result.PartialFailure = fail
break
}
if substituted != "" {
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
}
}
// Opt the new project into headless dispatch if asked. Two INDEPENDENT gates,
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
// allowlist. Skip both if substitution itself already stalled — don't mark an
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
// (gitea-mcp#51/#54) — never conflated with each other or with substitution,
// since any one of the three can fail independently of the other two.
if args.DispatchAllow && result.PartialFailure == "" {
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
result.DispatchAllowFailure = fail
} else if didWrite {
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
}
ownerName := args.Owner + "/" + args.Name
if didRegister, fail := t.registerDispatchAllowlist(ctx, ownerName); fail != "" {
result.DispatchAllowlistFailure = fail
} else if didRegister {
result.DispatchAllowlisted = true
}
}
// If substitution stalled because the generated branch wasn't writable in time,
// the repo IS created — say so clearly and point to the concrete recovery step
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
// gitea's template-generate is slow-async on this instance, so tool-side
// substitution is best-effort).
if strings.Contains(result.PartialFailure, "branch does not exist") ||
strings.Contains(result.PartialFailure, "not found") {
result.PartialFailure = infra179FinalizeMessage(
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud on a FRESH create with nothing substituted: a real template
// should have placeholders, so finding none is suspicious. On resume, nothing
// left to substitute is the expected steady state once a prior partial run is
// fully caught up — success, not a loud failure.
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
}
return textOK(result)
}
// createDestination generates a new repo from the template: verifies the
// template exists and the destination doesn't already exist, then calls
// gitea's /generate and resolves the default branch.
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
// Resolve template: per-call override takes precedence over the
// server-configured default. Owner stays server-configured.
tmplName := args.TemplateName
@@ -218,19 +103,17 @@ func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args
// Verify template exists and is marked as a template repo.
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
if err != nil {
return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
return nil, fmt.Errorf("template lookup: %w", err)
}
if !tmpl.Template {
return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
}
// Verify destination doesn't already exist.
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
return createProjectResult{}, "", fmt.Errorf(
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
args.Owner, args.Name, gitea.ErrConflict)
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) {
return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
return nil, fmt.Errorf("destination check: %w", err)
}
// Generate repo from template.
@@ -242,7 +125,7 @@ func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args
GitContent: true,
})
if err != nil {
return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
return nil, fmt.Errorf("generate: %w", err)
}
result := createProjectResult{
@@ -252,236 +135,21 @@ func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args
DefaultBranch: newRepo.DefaultBranch,
}
// The /generate response often omits default_branch — resolve it explicitly,
// otherwise every file read below hits an empty ref and nothing substitutes
// (the silent-null bug: gitea-mcp#42).
// Substitute placeholders in known files (best-effort).
repls := substitutions(args.Owner, args.Name)
branch := newRepo.DefaultBranch
if branch == "" {
if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
branch = r.DefaultBranch
} else {
branch = "main"
}
}
result.DefaultBranch = branch
return result, branch, nil
}
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
// nothing to resume without it. Template lookup and generation are skipped
// entirely: resume only ever operates on the destination.
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
repo, err := t.c.GetRepo(ctx, owner, name)
if err != nil {
for _, path := range substitutionFiles {
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
// Files that don't exist in this template are silently skipped.
if errors.Is(err, gitea.ErrNotFound) {
return createProjectResult{}, "", fmt.Errorf(
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
owner, name, gitea.ErrValidation)
continue
}
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
// Any other error halts the substitution pass with partial_failure recorded.
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
break
}
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
return createProjectResult{
FullName: repo.FullName,
HTMLURL: repo.HTMLURL,
CloneURL: repo.CloneURL,
DefaultBranch: branch,
}, branch, nil
result.FilesSubstituted = append(result.FilesSubstituted, path)
}
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
// on every resume, not just the first attempt): creates it if absent, updates
// it if present but different, leaves it untouched if already correct. Without
// this, a naive create-only write would error on a re-invoke (gitea rejects a
// create at a path that already exists) — that was the reported failure in
// gitea-mcp#51. Returns whether a write actually happened.
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
const path = ".dispatch-allow"
sha := ""
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
return false, "" // already present and correct — idempotent no-op
}
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
} else if !errors.Is(err, gitea.ErrNotFound) {
return false, fmt.Sprintf("read %s: %v", path, err)
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
Sha: sha,
}); err != nil {
return false, fmt.Sprintf("write %s: %v", path, err)
}
return true, ""
}
// registerDispatchAllowlist appends ownerName ("owner/name") to
// mathias/dispatch's git-tracked dispatch-repos.txt if not already listed
// (gitea-mcp#54) — idempotent, safe to call on every resume. This is the
// SECOND of the two required dispatch gates: being listed here is necessary
// but not sufficient on its own (the repo also needs its own .dispatch-allow
// marker, injectDispatchAllow's job) — dispatch#3's structural trust-zone
// design requires both independently. Returns whether a write actually
// happened, so the caller only reports a fresh registration, not a no-op.
func (t *CreateProjectFromTemplate) registerDispatchAllowlist(ctx context.Context, ownerName string) (didRegister bool, failure string) {
fc, err := t.c.GetFileContents(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, dispatchAllowlistBranch)
if err != nil {
return false, fmt.Sprintf("read %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return false, fmt.Sprintf("decode %s: %v", dispatchAllowlistPath, err)
}
content := string(decoded)
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == ownerName {
return false, "" // already listed — idempotent no-op
}
}
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
Branch: dispatchAllowlistBranch,
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
Sha: fc.Sha,
}); err != nil {
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
return true, ""
}
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
// async template-generate (infra#179) leaves the branch unwritable within the
// budget. It points at the concrete recovery step — re-invoking this same tool
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
// resume safely continues from wherever substitution stalled.
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
return fmt.Sprintf(
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
"template-generate is slow-async on this instance (infra#179), so substitution "+
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
"(same owner/name) once the branch is writable — it safely continues where this left "+
"off, skipping anything already correct. Underlying: %s",
branch, budget, done, underlying)
}
// substitutionBudget bounds how long we retry the first write while the freshly
// generated branch becomes writable. gitea's /generate returns (and serves reads)
// before the branch ref is committed, so writes 404 "branch does not exist" for a
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
// fails fast with partial_failure naming resume=true as the recovery path
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
const substitutionBudget = 5
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
// exist" not-found, up to substitutionBudget. The write itself is the readiness
// probe — BranchExists reports the branch present before writes succeed.
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
var err error
for i := 0; i < substitutionBudget; i++ {
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
return nil
}
if !errors.Is(err, gitea.ErrNotFound) {
return err
}
select {
case <-ctx.Done():
return err
case <-time.After(time.Second):
}
}
return err
}
// substituteEntry substitutes placeholders in one blob. If the path carries a
// placeholder it renames the file (write new + delete old); otherwise it rewrites
// content in place when changed. Returns a human-readable description of what was
// substituted ("" if nothing), and a non-empty partial-failure string on error.
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
newPath := applyReplacements(path, repls)
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
if err != nil {
if errors.Is(err, gitea.ErrNotFound) {
return "", "" // vanished between tree walk and read; skip
}
return "", fmt.Sprintf("read %s: %v", path, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return "", fmt.Sprintf("decode %s: %v", path, err)
}
newContent := applyReplacements(string(decoded), repls)
renamed := newPath != path
changed := newContent != string(decoded)
if !renamed && !changed {
return "", "" // nothing to do
}
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
if renamed {
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: "template: substitute placeholders",
Sha: fc.Sha,
}); err != nil {
return "", fmt.Sprintf("write %s: %v", path, err)
}
return path, ""
}
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
// are separate, non-atomic API calls) completes cleanly instead of erroring on
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
// already holds the correct content, the write is skipped and only the
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
// is a no-op too.
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
switch {
case err == nil:
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
if derr == nil && string(decoded) == newContent {
break // already correct from a prior partial run — skip the write
}
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc, Sha: existing.Sha,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
case errors.Is(err, gitea.ErrNotFound):
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
default:
return "", fmt.Sprintf("read %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
Branch: branch,
Sha: oldSha,
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
}
return oldPath + " -> " + newPath, ""
return textOK(result)
}
@@ -5,572 +5,318 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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 encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
var substitutionFileList = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func templateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
// contentWithPlaceholder is a template file body that contains the placeholder.
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
func encodedContent(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// fileContentsJSON returns a JSON FileContents object for the given path.
func fileContentsJSON(path string) string {
enc := encodedContent(contentWithPlaceholder)
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), enc)
}
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
func fileWriteResultJSON(path string) string {
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
}
// newTemplateRepoJSON returns a JSON Repo marked as template.
func newTemplateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
name, name, name, name, isTemplate)
}
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
type fakeTemplateServer struct {
mu sync.Mutex
files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool
generateCalls int // # times POST .../generate was hit — resume must never increment this
puts []string
deletes []string
putBodies map[string]string // path -> decoded written content
repoGetsPost int // GET dest after generate (branch fallback)
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
// "" (default) 404s the read, matching a repo that hasn't seeded the file
// (a distinct error path); tests that care set it explicitly.
dispatchRepos string
dispatchReposPuts int
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
func newGeneratedRepoJSON(name string) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
name, name, name, name)
}
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
c := gitea.NewClient(srvURL, "tok")
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
}
// newFakeTemplateServerResumed simulates a repo that already exists from a
// prior (real) generate call — GET dest succeeds immediately, without a
// /generate call first. Used for resume:true tests.
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
f := newFakeTemplateServer(files, genBranch)
f.generated = true
return f
}
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
// TestCreateProjectHappyPath: all 6 files served and substituted.
func TestCreateProjectHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
switch {
case r.Method == http.MethodGet && p == dispatchReposPath:
if f.dispatchRepos == "" {
// Template repo lookup
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
// Destination repo lookup — 404 means it doesn't exist yet
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos))
case r.Method == http.MethodPut && p == dispatchReposPath:
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.dispatchRepos = string(dec)
f.dispatchReposPuts++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
if !f.generated {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
f.repoGetsPost++
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
f.generated = true
f.generateCalls++
// Generate
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
dest, dest, f.genBranch, dest, dest)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
var entries []string
for path := range f.files {
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
}
// include a tree (directory) entry to exercise the blob filter
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
// File contents GET — handle all 6 substitution files
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
body, ok := f.files[path]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.puts = append(f.puts, path)
f.putBodies[path] = string(dec)
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
} else {
// File contents PUT — handle all 6 substitution files
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
f.deletes = append(f.deletes, path)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}
}
}))
defer srv.Close()
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))
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
require.NoError(t, err)
var out createOut
require.NoError(t, json.Unmarshal(res, &out))
return out
}
type createOut struct {
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"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
// 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()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
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.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.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure)
}
// Fail loud: a template whose files carry no placeholders yields nothing
// substituted — surface it rather than returning silent success.
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
files := map[string]string{"README.md": "# static, no placeholders\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
// server-configured default, so the same binary can generate from template-go-web or
// template-go-agent without restart.
func TestCreateProjectTemplateNameOverride(t *testing.T) {
var templateLookups, generateCalls []string
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) {
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
templateLookups = append(templateLookups, "template-go-agent")
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.NotEmpty(t, out.PartialFailure)
assert.Contains(t, out.PartialFailure, "go.mod")
}
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)))
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
func TestCreateProject_DispatchAllow(t *testing.T) {
tests := []struct {
name string
argsJSON string
wantFile bool
}{
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON)
require.Empty(t, out.PartialFailure)
if tc.wantFile {
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
assert.Contains(t, f.puts, ".dispatch-allow")
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
} else {
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
assert.NotContains(t, f.puts, ".dispatch-allow")
}
})
}
}
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
// resume:true requires an ALREADY-CREATED destination — there is nothing to
// resume otherwise.
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
}
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), "nothing to resume")
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
))
require.NoError(t, err)
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
"override must direct the template lookup, not the server default")
require.Len(t, generateCalls, 1)
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
"override must direct the /generate call too")
}
// resume:true on an existing repo continues substitution — fixes only what's
// still wrong, leaves already-correct files untouched, and never calls
// /generate again (the whole point: no re-creation, just continuation).
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Equal(t, 0, f.generateCalls, "resume must never call /generate")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
assert.Contains(t, f.puts, "README.md")
}
// resume:true when everything is already substituted is the expected steady
// state (a prior resume already finished the job, or this is a redundant
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
// that a fresh (non-resume) create would trigger.
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
"README.md": "# new-svc\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
}
// A resume where a prior partial run's rename write SUCCEEDED but its paired
// delete FAILED (both are separate, non-atomic API calls) must complete
// cleanly: recognize the new path is already correct, skip re-writing it, and
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
files := map[string]string{
// stray: delete never completed in the prior run
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
// already correct: the write half of the same prior rename DID complete
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
}
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
// has the correct content (from an earlier successful injection), re-invoking
// must not attempt another write — and must not error the way a naive
// create-only write would (gitea 409/422 on an existing path with no sha).
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
// Phase 1: a normal (non-resume) call captures the REAL content the tool
// writes for .dispatch-allow, without the test needing to know the exact
// unexported constant.
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
seedSrv := newFakeTemplateServer(seedFiles, "main")
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
srv1.Close()
require.Empty(t, out1.PartialFailure)
realContent := seedSrv.putBodies[".dispatch-allow"]
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
// one file still needing substitution.
files := map[string]string{
".dispatch-allow": realContent,
"README.md": "# __PROJECT_NAME__\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv2.Close()
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out2.PartialFailure)
assert.Empty(t, out2.DispatchAllowFailure)
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
assert.Contains(t, out2.FilesSubstituted, "README.md")
}
// dispatch_allow injection failing is reported in its OWN field, distinct from
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
// eligibility still fails, and the caller must be able to tell them apart.
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure")
assert.NotEmpty(t, out.DispatchAllowFailure)
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
}
// dispatch_allow now ALSO registers the new repo into mathias/dispatch's
// git-tracked allowlist (dispatch-repos.txt) — the second of the two required
// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54).
func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.True(t, out.DispatchAllowlisted)
assert.Equal(t, 1, f.dispatchReposPuts)
assert.Contains(t, f.dispatchRepos, "mathias/new-svc")
// existing entries preserved, not clobbered
assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox")
assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo")
}
// Registering is idempotent: a repo already listed (e.g. a resume re-running
// with dispatch_allow:true) must not produce a duplicate line or a redundant write.
func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) {
files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted
f := newFakeTemplateServerResumed(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n"
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure)
assert.Empty(t, out.DispatchAllowlistFailure)
assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration")
assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write")
}
// A failure registering the allowlist is reported in its OWN field — distinct
// from PartialFailure (substitution) AND DispatchAllowFailure (the marker
// file) — since all three are independent gates that can fail independently.
func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
f.dispatchRepos = "mathias/dispatch-sandbox\n"
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution succeeded — must not be conflated")
assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated")
assert.NotEmpty(t, out.DispatchAllowlistFailure)
assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure")
}
// dispatch_allow=false/omitted must never touch the allowlist file at all.
func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Empty(t, out.PartialFailure)
assert.False(t, out.DispatchAllowlisted)
assert.Equal(t, 0, f.dispatchReposPuts)
}
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
func TestCreateProject_NameRegexFailure(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
func TestCreateProjectNameRegexFailure(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestCreateProject_AllowlistRejects(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
func TestCreateProjectAllowlistRejects(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist")
}
func TestCreateProject_NotTemplate(t *testing.T) {
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
// Template lookup returns a non-template repo.
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
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)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestCreateProject_DestinationExists(t *testing.T) {
// TestCreateProjectDestinationExists: destination repo already exists.
func TestCreateProjectDestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/repos/mathias/template-go-agent":
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
case "/api/v1/repos/mathias/new-svc":
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
// Destination exists — return 200.
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
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)
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")
}
@@ -1,23 +0,0 @@
package tools
import (
"strings"
"testing"
)
// #46: the infra#179 finalize guidance must not point at a non-existent command
// (`hyperguild new-project` was never built). #50 superseded the original
// manual-editing guidance with a concrete, real recovery: re-invoke this same
// tool with resume=true.
func TestInfra179FinalizeMessage(t *testing.T) {
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q\ngot: %s", want, msg)
}
}
if strings.Contains(msg, "hyperguild new-project") {
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
}
}
+8 -8
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 DirList struct {
@@ -26,18 +26,18 @@ func (t *DirList) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","repo"]
"required":["owner","name"]
}`),
}
}
type dirListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
}
@@ -47,11 +47,11 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
entries, err := t.c.ListContents(ctx, args.Owner, args.Repo, args.Path, args.Ref)
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+8 -8
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 FileDelete struct {
@@ -27,20 +27,20 @@ func (t *FileDelete) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"branch":{"type":"string"},
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","repo","path","branch","message","sha"]
"required":["owner","name","path","branch","message","sha"]
}`),
}
}
type fileDeleteArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Path string `json:"path"`
Branch string `json:"branch"`
Message string `json:"message"`
@@ -52,7 +52,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Sha == "" {
@@ -62,7 +62,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
}
result, err := t.c.DeleteFile(ctx, args.Owner, args.Repo, args.Path, gitea.DeleteFileArgs{
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
Branch: args.Branch,
Message: args.Message,
Sha: args.Sha,
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+9 -9
View File
@@ -6,9 +6,9 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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"
)
const fileReadMaxBytes = 1 << 20 // 1 MiB
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","repo","path"]
"required":["owner","name","path"]
}`),
}
}
type fileReadArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
}
@@ -51,20 +51,20 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
ref := args.Ref
if ref == "" {
var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, ref)
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
if err != nil {
return nil, err
}
+3 -18
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
@@ -57,21 +57,6 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
assert.Equal(t, "main", result["ref"])
}
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Gitea returns an array when path is a directory
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
}))
defer srv.Close()
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "directory")
assert.Contains(t, err.Error(), "dir_list")
}
func TestFileReadAllowlistRejects(t *testing.T) {
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
+12 -12
View File
@@ -6,9 +6,9 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"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 FileWriteBranch struct {
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "file_write_branch",
Description: "Create or update a file on the given branch. Pass an existing branch — e.g. the default branch `main` — to commit directly to it (trunk-based); a branch that doesn't exist yet is created from `base` first (PR flow). Pair with pr_create/pr_merge for the branch→PR→merge path.",
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"content":{"type":"string"},
"branch":{"type":"string"},
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","repo","path","content","branch","message"]
"required":["owner","name","path","content","branch","message"]
}`),
}
}
type fileWriteBranchArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Path string `json:"path"`
Content string `json:"content"`
Branch string `json:"branch"`
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
@@ -68,7 +68,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
}
// Resolve base default if branch needs to be created
exists, err := t.c.BranchExists(ctx, args.Owner, args.Repo, args.Branch)
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
if err != nil {
return nil, err
}
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
base := args.Base
if base == "" {
var err error
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, args.Branch, base); err != nil {
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
return nil, err
}
}
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
result, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
Branch: args.Branch,
Content: encoded,
Message: args.Message,
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
-56
View File
@@ -1,56 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueClose struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
return &IssueClose{c: c, a: a}
}
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_close",
Description: "Close an open issue. Reversible via issue_reopen.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueCloseArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCloseArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
if err != nil {
return nil, err
}
return textOK(iss)
}
-52
View File
@@ -1,52 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueCloseTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"closed"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"closed"`)
}
func TestIssueCloseTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueCloseAllowlistRejects(t *testing.T) {
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueComment struct {
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","repo","number","body"]
"required":["owner","name","number","body"]
}`),
}
}
type issueCommentArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueCreate struct {
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"labels":{"type":"array","items":{"type":"integer"}},
"assignees":{"type":"array","items":{"type":"string"}},
"milestone":{"type":"integer"}
},
"required":["owner","repo","title"]
"required":["owner","name","title"]
}`),
}
}
type issueCreateArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels"`
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Repo, gitea.CreateIssueArgs{
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
Title: args.Title,
Body: body,
Labels: args.Labels,
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
-80
View File
@@ -1,80 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueEdit struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit {
return &IssueEdit{c: c, a: a}
}
func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_edit",
Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " +
"omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " +
"WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " +
"(fetch with issue_get, edit the text, send it back).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
},
"required":["owner","repo","number"]
}`),
}
}
type issueEditArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
}
func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueEditArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation)
}
if args.Title == nil && args.Body == nil {
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
}
iss, err := t.c.EditIssue(ctx, args.Owner, args.Repo, args.Number, gitea.EditIssueArgs{
Title: args.Title,
Body: args.Body,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": iss.Number,
"title": iss.Title,
"html_url": iss.HTMLURL,
"state": iss.State,
})
}
-91
View File
@@ -1,91 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueEditTool(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "new", payload["title"])
assert.Equal(t, "updated body", payload["body"])
assert.Contains(t, string(out), `"number":26`)
}
// Body must be sent verbatim — no identity footer appended (keeps edits idempotent).
func TestIssueEditTool_BodyVerbatim(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"state":"open"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer")
_, hasTitle := payload["title"]
assert.False(t, hasTitle, "title must be omitted when not provided")
}
func TestIssueEditTool_RequiresAField(t *testing.T) {
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestIssueEditTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
title := "x"
body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title})
_, err := tool.Call(context.Background(), body)
require.Error(t, err)
}
func TestIssueEditAllowlistRejects(t *testing.T) {
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`))
require.Error(t, err)
}
-54
View File
@@ -1,54 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_get",
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueGetArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, err
}
return textOK(iss)
}
-50
View File
@@ -1,50 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueGetTool(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", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
}))
defer srv.Close()
tool := tools.NewIssueGet(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), `"number":42`)
assert.Contains(t, string(out), `"title":"fix auth"`)
assert.Contains(t, string(out), `"comments":3`)
}
func TestIssueGetTool_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.NewIssueGet(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 TestIssueGetAllowlistRejects(t *testing.T) {
tool := tools.NewIssueGet(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)
}
-87
View File
@@ -1,87 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueLabel struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
return &IssueLabel{c: c, a: a}
}
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_label",
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"labels":{"type":"array","items":{"type":"string"},"description":"Label names to resolve and apply. Either labels or label_ids is required."},
"label_ids":{"type":"array","items":{"type":"integer"},"description":"Label IDs to apply directly, skipping name resolution. Either labels or label_ids is required."}
},
"required":["owner","repo","number"]
}`),
}
}
type issueLabelArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Labels []string `json:"labels,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
}
func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueLabelArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
if len(args.Labels) == 0 && len(args.LabelIDs) == 0 {
return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation)
}
ids := append([]int64{}, args.LabelIDs...)
if len(args.Labels) > 0 {
existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
byName := make(map[string]int64, len(existing))
for _, l := range existing {
byName[l.Name] = l.ID
}
for _, name := range args.Labels {
id, ok := byName[name]
if !ok {
return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation)
}
ids = append(ids, id)
}
}
labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids)
if err != nil {
return nil, err
}
return textOK(labels)
}
-128
View File
@@ -1,128 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const labelListFixture = `[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`
func TestIssueLabelAppliesByName(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
// label_ids alone (no labels) must work end-to-end without hitting ListLabels
// at all — this is the schema-level "either labels or label_ids" contract, and
// it must never require a GET to the label list when the caller already has IDs.
func TestIssueLabelAppliesByIDOnly(t *testing.T) {
var captured []byte
var listCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
listCalled = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"label_ids":[1,2]}`))
require.NoError(t, err)
assert.False(t, listCalled, "label_ids-only must not call ListLabels")
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
}
// #52 review finding: the advertised schema wrongly required "labels", making
// label_ids-only calls fail JSON-Schema validation before reaching Call at all.
// Lock the fixed contract: neither is individually required.
func TestIssueLabelSchema_NeitherLabelsNorLabelIDsRequired(t *testing.T) {
sch := string(tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})).Descriptor().InputSchema)
assert.NotContains(t, sch, `"required":["owner","repo","number","labels"]`)
assert.Contains(t, sch, `"required":["owner","repo","number"]`)
}
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), `"does-not-exist"`)
assert.Contains(t, err.Error(), "o/r")
}
func TestIssueLabelAllowlistRejects(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
require.Error(t, err)
}
func TestIssueLabelRequiresValidNumber(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
-83
View File
@@ -1,83 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
return &IssueList{c: c, a: a}
}
func (t *IssueList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_list",
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"state":{"type":"string","enum":["open","closed","all"]},
"labels":{"type":"string"},
"since":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","repo"]
}`),
}
}
type issueListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
State string `json:"state"`
Labels string `json:"labels"`
Since string `json:"since"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.State == "" {
args.State = "open"
}
args.Limit = capLimit(args.Limit, 30)
if args.Page < 1 {
args.Page = 1
}
issues, err := t.c.ListIssues(ctx, args.Owner, args.Repo, gitea.ListIssuesArgs{
State: args.State,
Labels: args.Labels,
Since: args.Since,
Page: args.Page,
Limit: args.Limit,
})
if err != nil {
return nil, err
}
out := map[string]any{
"issues": issues,
}
if len(issues) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
-56
View File
@@ -1,56 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueListComments struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueListComments(c *gitea.Client, a *allowlist.Allowlist) *IssueListComments {
return &IssueListComments{c: c, a: a}
}
func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_list_comments",
Description: "List all comments on an issue or pull request. Returns id, body, author, html_url, and timestamps for each comment.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueListCommentsArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueListCommentsArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, err
}
return textOK(comments)
}
@@ -1,67 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueListCommentsTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42/comments", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"body":"first","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"second","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"id":1`)
assert.Contains(t, string(out), `"body":"first"`)
assert.Contains(t, string(out), `"login":"alice"`)
assert.Contains(t, string(out), `"login":"bob"`)
}
func TestIssueListCommentsTool_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
require.NoError(t, err)
assert.Contains(t, string(out), `[]`)
}
func TestIssueListCommentsTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueListCommentsAllowlistRejects(t *testing.T) {
tool := tools.NewIssueListComments(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
-88
View File
@@ -1,88 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueListTool(t *testing.T) {
tests := []struct {
name string
input string
wantQuery map[string]string
respBody string
assert func(t *testing.T, out string)
}{
{
name: "happy path defaults",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"number":42`)
assert.Contains(t, out, `"number":41`)
},
},
{
name: "state filter",
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
wantQuery: map[string]string{"type": "issues", "state": "closed"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
},
},
{
name: "label + since filter",
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
respBody: `[]`,
assert: func(t *testing.T, out string) {},
},
{
name: "empty result",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"state": "open"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
assert.NotContains(t, out, `next_page`)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
q := r.URL.Query()
for k, v := range tc.wantQuery {
assert.Equal(t, v, q.Get(k), "query param %q", k)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.respBody))
}))
defer srv.Close()
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
require.NoError(t, err)
tc.assert(t, string(out))
})
}
}
func TestIssueListAllowlistRejects(t *testing.T) {
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}
-56
View File
@@ -1,56 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueReopen struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
return &IssueReopen{c: c, a: a}
}
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_reopen",
Description: "Reopen a closed issue. Reversible via issue_close.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","repo","number"]
}`),
}
}
type issueReopenArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueReopenArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
if err != nil {
return nil, err
}
return textOK(iss)
}
-40
View File
@@ -1,40 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueReopenTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"open"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"open"`)
}
func TestIssueReopenAllowlistRejects(t *testing.T) {
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
-54
View File
@@ -1,54 +0,0 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type LabelList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
return &LabelList{c: c, a: a}
}
func (t *LabelList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "label_list",
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"}
},
"required":["owner","repo"]
}`),
}
}
type labelListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args labelListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
return textOK(labels)
}
-42
View File
@@ -1,42 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLabelListTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"id":1`)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"color":"ee0701"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
func TestLabelListAllowlistRejects(t *testing.T) {
tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`))
require.Error(t, err)
}
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRComment struct {
@@ -29,18 +29,18 @@ func (t *PRComment) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","repo","number","body"]
"required":["owner","name","number","body"]
}`),
}
}
type prCommentArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
@@ -50,7 +50,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
@@ -61,7 +61,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"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"
)
+10 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRCreate struct {
@@ -29,21 +29,21 @@ func (t *PRCreate) Descriptor() registry.ToolDescriptor {
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"name":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"head":{"type":"string"},
"base":{"type":"string"},
"draft":{"type":"boolean"}
},
"required":["owner","repo","title","head","base"]
"required":["owner","name","title","head","base"]
}`),
}
}
type prCreateArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
Head string `json:"head"`
@@ -56,7 +56,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(ctx, args.Owner); err != nil {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
@@ -68,7 +68,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Repo, gitea.CreatePullRequestArgs{
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Name, gitea.CreatePullRequestArgs{
Title: args.Title,
Body: body,
Head: args.Head,

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