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
133 changed files with 1107 additions and 5330 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. These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting. Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -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. 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 Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior). 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 ## 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) | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering - **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main, - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set`
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure ## Infrastructure
@@ -160,64 +100,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management - **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform - **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, When available, agents can query the shared knowledge base:
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 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 <!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours. name once hyperguild is deployed. Until then, agents that try to
- **When debugging** — search for the error string, the stack frame, the affected reach the knowledge service on a host where it isn't running will
service. Past you may have already paid this tax. get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Before adopting** a pattern, library, framework, or model name — check if it - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules ## Client work rules
@@ -263,17 +157,15 @@ unconditionally on every host, every harness.
## Engineering Skills ## 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 | Key skills:
|-----------|------| - **TDD**: always write tests first — load `tdd` skill
| Any feature or bug fix | `tdd` | - **Code Review**: load `code-review` skill before any review
| Refactor or design | `clean-code` or `solid` | - **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
| Debug | `problem-analysis` | - **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
@@ -288,7 +180,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -316,11 +208,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
+4 -32
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -37,11 +37,8 @@
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
+22 -158
View File
@@ -32,14 +32,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting. Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -49,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. 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 Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior). 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 ## 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) | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering - **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main, - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure ## Infrastructure
@@ -165,64 +105,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management - **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform - **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, When available, agents can query the shared knowledge base:
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 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 <!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours. name once hyperguild is deployed. Until then, agents that try to
- **When debugging** — search for the error string, the stack frame, the affected reach the knowledge service on a host where it isn't running will
service. Past you may have already paid this tax. get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Before adopting** a pattern, library, framework, or model name — check if it - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules ## Client work rules
@@ -268,17 +162,15 @@ unconditionally on every host, every harness.
## Engineering Skills ## 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 | Key skills:
|-----------|------| - **TDD**: always write tests first — load `tdd` skill
| Any feature or bug fix | `tdd` | - **Code Review**: load `code-review` skill before any review
| Refactor or design | `clean-code` or `solid` | - **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
| Debug | `problem-analysis` | - **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
@@ -293,7 +185,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -321,11 +213,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
--- ---
+22 -158
View File
@@ -30,14 +30,6 @@ and climate/sustainability tech.
These rules apply to every task across every project, regardless of harness. These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting. Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -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. 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 Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior). 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 ## 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) | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering - **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main, - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure ## Infrastructure
@@ -163,64 +103,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management - **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform - **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, When available, agents can query the shared knowledge base:
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 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 <!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours. name once hyperguild is deployed. Until then, agents that try to
- **When debugging** — search for the error string, the stack frame, the affected reach the knowledge service on a host where it isn't running will
service. Past you may have already paid this tax. get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Before adopting** a pattern, library, framework, or model name — check if it - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules ## Client work rules
@@ -266,17 +160,15 @@ unconditionally on every host, every harness.
## Engineering Skills ## 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 | Key skills:
|-----------|------| - **TDD**: always write tests first — load `tdd` skill
| Any feature or bug fix | `tdd` | - **Code Review**: load `code-review` skill before any review
| Refactor or design | `clean-code` or `solid` | - **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
| Debug | `problem-analysis` | - **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
@@ -291,7 +183,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -319,11 +211,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
+4 -1
View File
@@ -1,9 +1,11 @@
name: CD name: CD
"on": on:
push: push:
branches: [main] branches: [main]
tags: ["v*"] tags: ["v*"]
pull_request:
branches: [main]
env: env:
IMAGE: gitea-mcp IMAGE: gitea-mcp
@@ -41,6 +43,7 @@ jobs:
name: Build & Import name: Build & Import
needs: check needs: check
runs-on: self-hosted runs-on: self-hosted
if: github.event_name != 'pull_request'
outputs: outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }} image-tag: ${{ steps.meta.outputs.sha-tag }}
steps: steps:
-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. These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting. Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing 2. **Minimum viable code.** Solve with the smallest change that works. Nothing
@@ -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. 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 Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior). 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 ## 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) | — | | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — | | Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — | | Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these. 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 - **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering - **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main, - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set`
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure ## Infrastructure
@@ -160,64 +100,18 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management - **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform - **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, When available, agents can query the shared knowledge base:
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 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 <!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours. name once hyperguild is deployed. Until then, agents that try to
- **When debugging** — search for the error string, the stack frame, the affected reach the knowledge service on a host where it isn't running will
service. Past you may have already paid this tax. get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Before adopting** a pattern, library, framework, or model name — check if it - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules ## Client work rules
@@ -263,17 +157,15 @@ unconditionally on every host, every harness.
## Engineering Skills ## 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 | Key skills:
|-----------|------| - **TDD**: always write tests first — load `tdd` skill
| Any feature or bug fix | `tdd` | - **Code Review**: load `code-review` skill before any review
| Refactor or design | `clean-code` or `solid` | - **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
| Debug | `problem-analysis` | - **Problem first**: load `problem-analysis` skill before coding non-trivial features
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
--- ---
@@ -288,7 +180,7 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -316,11 +208,8 @@ Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/ma
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
+4 -32
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp - **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
@@ -37,11 +37,8 @@
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - Branch naming: `feat/short-description`, `fix/short-description`
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - PRs: one concern per PR, description explains *why* not *what*
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security ### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files - 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 3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
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.
-11
View File
@@ -1,16 +1,5 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src 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 ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
-11
View File
@@ -2,14 +2,3 @@
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps. 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`. 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: cmds:
- bash scripts/context-sync.sh - 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: context:sync:claude:
cmds: [bash scripts/context-sync.sh claude] cmds: [bash scripts/context-sync.sh claude]
context:sync:agents: 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)
})
}
}
+57 -37
View File
@@ -2,32 +2,22 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/config" "gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
) )
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
// "0.1.0" — it now tells the truth instead of a stale literal).
var version = "dev"
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 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() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -37,44 +27,74 @@ func main() {
ctx := context.Background() ctx := context.Background()
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience) jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
if jwtInitErr != nil { if err != nil {
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr) logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
} }
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken) giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners) ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New() reg := registry.New()
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{ mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg, Registry: reg,
Sessions: mcp.NewSessionStore(), Sessions: mcp.NewSessionStore(),
}) })
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
// when both MCPResourceURL and a Dex issuer are wired; empty disables
// the challenge so static-only clients aren't pushed into OAuth discovery.
var resourceMetadataURL string
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL, auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
auth.CallerMiddleware(logger, mcpSrv), auth.CallerMiddleware(mcpSrv),
), ),
)) ))
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
if cfg.DexIssuerURL != "" { w.WriteHeader(http.StatusOK)
mux.HandleFunc("GET /.well-known/oauth-protected-resource", _, _ = w.Write([]byte("ok"))
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL)) })
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
} }
w.Header().Set("Content-Type", "application/json")
payload := map[string]any{
"resource": cfg.MCPResourceURL,
"authorization_servers": []string{},
}
if cfg.DexIssuerURL != "" {
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
}
_ = json.NewEncoder(w).Encode(payload)
})
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", version) logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err) logger.Error("server stopped", "err", err)
os.Exit(1) os.Exit(1)
+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 go 1.26.2
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.3.0
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
@@ -16,7 +16,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
-2
View File
@@ -1,5 +1,3 @@
git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0=
git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -3,7 +3,7 @@ package allowlist_test
import ( import (
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
+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)
}
+3 -26
View File
@@ -2,40 +2,17 @@ package auth
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
) )
type ctxKey struct{} type ctxKey struct{}
// CallerMiddleware extracts the authenticated username from the reverse-proxy func CallerMiddleware(next http.Handler) http.Handler {
// identity headers and stashes it in the request context for Caller().
//
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
// convention some setups populate instead; it is used only as a fallback when
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
// verified X-Auth-Request-User still wins and we log a warning so the
// misconfiguration is visible rather than silently resolved (#10).
//
// logger may be nil, in which case the conflict warning is skipped.
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authUser := r.Header.Get("X-Auth-Request-User") user := r.Header.Get("X-Auth-Request-User")
fwdUser := r.Header.Get("X-Forwarded-User")
user := authUser
if 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)
}
ctx := context.WithValue(r.Context(), ctxKey{}, user) ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
+10 -64
View File
@@ -1,80 +1,26 @@
package auth_test package auth_test
import ( import (
"bytes"
"context" "context"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func discardLogger() *slog.Logger { func TestCallerFromContext(t *testing.T) {
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil)) called := false
} h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
called = true
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
// absent.
func TestCallerHeaderPrecedence(t *testing.T) {
tests := []struct {
name string
authReq string
forwarded string
wantCaller string
}{
{"auth-request only", "mathiasbq", "", "mathiasbq"},
{"forwarded fallback", "", "fwduser", "fwduser"},
{"both present, same", "same", "same", "same"},
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
{"neither", "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var got string
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = auth.Caller(r.Context())
})) }))
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.authReq != "" { req.Header.Set("X-Auth-Request-User", "mathiasbq")
req.Header.Set("X-Auth-Request-User", tc.authReq) rr := httptest.NewRecorder()
} h.ServeHTTP(rr, req)
if tc.forwarded != "" { assert.True(t, called)
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")
} }
func TestCallerEmptyWhenHeaderMissing(t *testing.T) { 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
}
+1 -1
View File
@@ -3,7 +3,7 @@ package config_test
import ( import (
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
-19
View File
@@ -3,10 +3,8 @@ package gitea
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/hashicorp/golang-lru/v2/expirable" "github.com/hashicorp/golang-lru/v2/expirable"
@@ -42,21 +40,7 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
return repo.DefaultBranch, nil return repo.DefaultBranch, nil
} }
// hasEmptySegment reports whether the path portion (before any query string)
// contains an empty segment ("//"), which means an owner or repo path
// parameter was empty. Forwarding it upstream yields gitea's opaque
// /api/swagger 404 (#36), so callers reject it locally instead.
func hasEmptySegment(path string) bool {
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
return strings.Contains(path, "//")
}
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
if hasEmptySegment(path) {
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
@@ -123,9 +107,6 @@ type rawResponse struct {
} }
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) { func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
if hasEmptySegment(path) {
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
}
var reader io.Reader var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
-54
View File
@@ -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 -1
View File
@@ -7,7 +7,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors" "errors"
"testing" "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/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 { if err := MapStatus(status, body); err != nil {
return nil, err 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 var fc FileContents
if err := json.Unmarshal(body, &fc); err != nil { if err := json.Unmarshal(body, &fc); err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
-150
View File
@@ -4,8 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strconv"
) )
type Issue struct { type Issue struct {
@@ -14,20 +12,6 @@ type Issue struct {
Body string `json:"body"` Body string `json:"body"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
State string `json:"state"` State string `json:"state"`
CreatedAt string `json:"created_at"`
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"`
}
type User struct {
Login string `json:"login"`
} }
type CreateIssueArgs struct { type CreateIssueArgs struct {
@@ -38,22 +22,6 @@ type CreateIssueArgs struct {
Milestone int64 `json:"milestone,omitempty"` 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) { 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) p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args) payload, err := json.Marshal(args)
@@ -74,128 +42,10 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
return &iss, nil 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 { type IssueComment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Body string `json:"body"` Body string `json:"body"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
User User `json:"user,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// ListIssueComments fetches all comments on an issue or pull request.
// Per Gitea, /issues/{index}/comments serves both since PRs share index space with issues.
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int) ([]IssueComment, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var comments []IssueComment
if err := json.Unmarshal(body, &comments); err != nil {
return nil, err
}
return comments, nil
} }
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint // CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
+1 -170
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -45,127 +45,6 @@ func TestCreateIssue(t *testing.T) {
assert.Equal(t, "open", iss.State) 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) { func TestCreateIssueComment(t *testing.T) {
var captured []byte var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -191,51 +70,3 @@ func TestCreateIssueComment(t *testing.T) {
assert.Equal(t, "hello", comment.Body) assert.Equal(t, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL) assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
} }
func TestListIssueComments(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
require.Len(t, comments, 2)
assert.Equal(t, int64(1), comments[0].ID)
assert.Equal(t, "first", comments[0].Body)
assert.Equal(t, "alice", comments[0].User.Login)
assert.Equal(t, "bob", comments[1].User.Login)
}
func TestListIssueComments_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Empty(t, comments)
}
func TestListIssueComments_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
-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"` Draft bool `json:"draft"`
Head struct { Head struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Sha string `json:"sha"`
} `json:"head"` } `json:"head"`
Base struct { Base struct {
Ref string `json:"ref"` Ref string `json:"ref"`
} `json:"base"` } `json:"base"`
Mergeable bool `json:"mergeable"`
} }
type CreatePullRequestArgs struct { type CreatePullRequestArgs struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+30 -169
View File
@@ -18,109 +18,6 @@ type Repo struct {
Template bool `json:"template"` 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) { func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
if page < 1 { if page < 1 {
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 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) { func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
body, status, err := c.GetJSON(ctx, path) 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 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" "sync/atomic"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -47,129 +47,6 @@ func TestListRepos(t *testing.T) {
assert.Equal(t, "main", repos[0].DefaultBranch) 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) { func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
var hits int32 var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+26 -76
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"strings"
) )
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger. // DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
@@ -14,45 +14,51 @@ type DispatchWorkflowArgs struct {
Inputs map[string]any `json:"inputs,omitempty"` Inputs map[string]any `json:"inputs,omitempty"`
} }
// DispatchWorkflow triggers a workflow_dispatch event. Gitea returns 204 No // WorkflowRunTrigger holds the run ID extracted from the Location header.
// Content with NO Location header, so the response carries no run ID — callers type WorkflowRunTrigger struct {
// resolve the new run separately via ListWorkflowRuns. Returns nil on success. RunID int64
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) error { }
// 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) p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
payload, err := json.Marshal(args) payload, err := json.Marshal(args)
if err != nil { if err != nil {
return err return nil, err
} }
resp, err := c.doRaw(ctx, "POST", p, payload) resp, err := c.doRaw(ctx, "POST", p, payload)
if err != nil { if err != nil {
return err return nil, err
} }
if resp.Status != 204 { if resp.Status != 204 {
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil { 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. // WorkflowRun represents a Gitea Actions run.
type WorkflowRun struct { type WorkflowRun struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DisplayTitle string `json:"display_title,omitempty"`
Status string `json:"status"` // queued | in_progress | completed Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when 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"` StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at,omitempty"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
Actor struct {
Login string `json:"login"`
} `json:"actor,omitempty"`
} }
// GetWorkflowRun fetches the status of a specific Actions run. // 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 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" "net/http/httptest"
"testing" "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/assert"
"github.com/stretchr/testify/require" "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) { func TestDispatchWorkflow(t *testing.T) {
var gotBody []byte var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -25,17 +22,18 @@ func TestDispatchWorkflow(t *testing.T) {
var err error var err error
gotBody, err = io.ReadAll(r.Body) gotBody, err = io.ReadAll(r.Body)
assert.NoError(t, err) 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) w.WriteHeader(http.StatusNoContent)
})) }))
defer srv.Close() defer srv.Close()
c := gitea.NewClient(srv.URL, "tok") 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", Ref: "main",
Inputs: map[string]any{"env": "prod"}, Inputs: map[string]any{"env": "prod"},
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(789), result.RunID)
var body map[string]any var body map[string]any
require.NoError(t, json.Unmarshal(gotBody, &body)) require.NoError(t, json.Unmarshal(gotBody, &body))
@@ -45,6 +43,19 @@ func TestDispatchWorkflow(t *testing.T) {
assert.Equal(t, "prod", inputs["env"]) 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) { func TestDispatchWorkflowError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -52,7 +63,7 @@ func TestDispatchWorkflowError404(t *testing.T) {
defer srv.Close() defer srv.Close()
c := gitea.NewClient(srv.URL, "tok") 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) require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound)) assert.True(t, errors.Is(err, gitea.ErrNotFound))
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package identity_test
import ( import (
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/identity" "gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"testing" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/assert"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
const ( const (
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp" "gitea.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/registry"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"sync" "sync"
"testing" "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/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":`)
}
+7 -7
View File
@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type BranchDelete struct { type BranchDelete struct {
@@ -27,17 +27,17 @@ func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"branch":{"type":"string"} "branch":{"type":"string"}
}, },
"required":["owner","repo","branch"] "required":["owner","name","branch"]
}`), }`),
} }
} }
type branchDeleteArgs struct { type branchDeleteArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Branch string `json:"branch"` Branch string `json:"branch"`
} }
@@ -53,7 +53,7 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation) 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 return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+7 -7
View File
@@ -4,9 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type BranchList struct { type BranchList struct {
@@ -26,18 +26,18 @@ func (t *BranchList) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"page":{"type":"integer","minimum":1}, "page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50} "limit":{"type":"integer","minimum":1,"maximum":50}
}, },
"required":["owner","repo"] "required":["owner","name"]
}`), }`),
} }
} }
type branchListArgs struct { type branchListArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Page int `json:"page"` Page int `json:"page"`
Limit int `json:"limit"` Limit int `json:"limit"`
} }
@@ -51,7 +51,7 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+7 -7
View File
@@ -4,9 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type BranchProtectionGet struct { type BranchProtectionGet struct {
@@ -26,17 +26,17 @@ func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"branch":{"type":"string"} "branch":{"type":"string"}
}, },
"required":["owner","repo","branch"] "required":["owner","name","branch"]
}`), }`),
} }
} }
type branchProtectionGetArgs struct { type branchProtectionGetArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Branch string `json:"branch"` Branch string `json:"branch"`
} }
@@ -49,7 +49,7 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+4 -4
View File
@@ -8,9 +8,9 @@ import (
"sync" "sync"
"time" "time"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type semaphore chan struct{} type semaphore chan struct{}
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
type codeSearchArgs struct { type codeSearchArgs struct {
Q string `json:"q"` Q string `json:"q"`
Owner string `json:"owner"` 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"` Page int `json:"page"`
Limit int `json:"limit"` Limit int `json:"limit"`
} }
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+24 -192
View File
@@ -2,37 +2,34 @@ package tools
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strings"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`) var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
var substitutionFiles = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func substitutions(owner, name string) map[string]string { func substitutions(owner, name string) map[string]string {
return map[string]string{ return map[string]string{
"__PROJECT_NAME__": name, "__PROJECT_NAME__": name,
// git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be "__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
// rename; a stale host breaks `go mod download` for downstream consumers).
"__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name,
} }
} }
func applyReplacements(s string, repls map[string]string) string {
for k, v := range repls {
s = strings.ReplaceAll(s, k, v)
}
return s
}
// CreateProjectFromTemplate is the exported type so tests can reference it. // CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct { type CreateProjectFromTemplate struct {
c *gitea.Client c *gitea.Client
@@ -48,7 +45,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "create_project_from_template", Name: "create_project_from_template",
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finish substituting the placeholders manually. Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).", 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(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
@@ -56,8 +53,7 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"}, "description":{"type":"string"},
"private":{"type":"boolean"}, "private":{"type":"boolean"},
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}, "template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."}
}, },
"required":["owner","name"] "required":["owner","name"]
}`), }`),
@@ -70,15 +66,8 @@ type createProjectArgs struct {
Description string `json:"description"` Description string `json:"description"`
Private bool `json:"private"` Private bool `json:"private"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
DispatchAllow bool `json:"dispatch_allow"`
} }
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
// sandbox convention: presence of the file (not its content) marks the repo
// dispatch-eligible; the comment exists only to explain that to a human reader.
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
"# See dispatch#3.\n"
type createProjectResult struct { type createProjectResult struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
@@ -146,178 +135,21 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
DefaultBranch: newRepo.DefaultBranch, DefaultBranch: newRepo.DefaultBranch,
} }
// The /generate response often omits default_branch — resolve it explicitly, // Substitute placeholders in known files (best-effort).
// otherwise every file read below hits an empty ref and nothing substitutes
// (the silent-null bug: gitea-mcp#42).
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
// Substitute across the WHOLE tree: content in every blob, plus a path rename
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
// A fixed known-files list can't rename directories or cover every templated
// file, which is why the old scaffold didn't build.
repls := substitutions(args.Owner, args.Name) repls := substitutions(args.Owner, args.Name)
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true) branch := newRepo.DefaultBranch
if err != nil { for _, path := range substitutionFiles {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err) if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
return textOK(result) // Files that don't exist in this template are silently skipped.
} if errors.Is(err, gitea.ErrNotFound) {
for _, e := range tree.Tree {
if e.Type != "blob" {
continue continue
} }
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls) // Any other error halts the substitution pass with partial_failure recorded.
if fail != "" { result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
result.PartialFailure = fail
break break
} }
if substituted != "" { result.FilesSubstituted = append(result.FilesSubstituted, path)
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
}
}
// Opt the new project into headless dispatch if asked: presence of a
// .dispatch-allow file on the default branch marks it dispatch-eligible
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
// loop already stalled — a failed injection then degrades identically.
if args.DispatchAllow && result.PartialFailure == "" {
const dispatchAllowPath = ".dispatch-allow"
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
}); err != nil {
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
} else {
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
}
}
// If substitution stalled because the generated branch wasn't writable in time,
// the repo IS created — say so clearly and point to the local finalize step,
// rather than leaking the raw "branch does not exist" (infra#179: gitea's
// template-generate is slow-async on this instance, so tool-side substitution
// is best-effort).
if strings.Contains(result.PartialFailure, "branch does not exist") ||
strings.Contains(result.PartialFailure, "not found") {
result.PartialFailure = infra179FinalizeMessage(
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
// substituted (with no explicit failure) means the walk found no placeholders —
// suspicious for a real template. Surface it instead of returning silent success.
if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
} }
return textOK(result) return textOK(result)
} }
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
// async template-generate (infra#179) leaves the branch unwritable within the
// budget. It names the concrete remaining work — substituting the two
// placeholders — rather than pointing at a specific tool, so the guidance stays
// correct regardless of scaffolding-CLI state (gitea-mcp#46).
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). Finish it by cloning the repo and replacing the "+
"remaining __PROJECT_NAME__ / __MODULE_PATH__ placeholders (in file contents and "+
"paths), then pushing; or retry create once the branch settles. 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 and we defer substitution with clear guidance rather than hang.
const substitutionBudget = 5
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
// exist" not-found, up to substitutionBudget. The write itself is the readiness
// probe — BranchExists reports the branch present before writes succeed.
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
var err error
for i := 0; i < substitutionBudget; i++ {
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
return nil
}
if !errors.Is(err, gitea.ErrNotFound) {
return err
}
select {
case <-ctx.Done():
return err
case <-time.After(time.Second):
}
}
return err
}
// substituteEntry substitutes placeholders in one blob. If the path carries a
// placeholder it renames the file (write new + delete old); otherwise it rewrites
// content in place when changed. Returns a human-readable description of what was
// substituted ("" if nothing), and a non-empty partial-failure string on error.
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
newPath := applyReplacements(path, repls)
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
if err != nil {
if errors.Is(err, gitea.ErrNotFound) {
return "", "" // vanished between tree walk and read; skip
}
return "", fmt.Sprintf("read %s: %v", path, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return "", fmt.Sprintf("decode %s: %v", path, err)
}
newContent := applyReplacements(string(decoded), repls)
renamed := newPath != path
changed := newContent != string(decoded)
if !renamed && !changed {
return "", "" // nothing to do
}
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
if renamed {
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
}); err != nil {
return "", fmt.Sprintf("write %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
Branch: branch,
Sha: fc.Sha,
Message: fmt.Sprintf("template: drop placeholder path %s", path),
}); err != nil {
return "", fmt.Sprintf("delete %s: %v", path, err)
}
return path + " -> " + newPath, ""
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: enc,
Message: "template: substitute placeholders",
Sha: fc.Sha,
}); err != nil {
return "", fmt.Sprintf("write %s: %v", path, err)
}
return path, ""
}
@@ -5,308 +5,318 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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 { // contentWithPlaceholder is a template file body that contains the placeholder.
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}`, 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) name, name, name, name, isTemplate)
} }
// fakeTemplateServer serves the whole create-from-template flow off an in-memory // newGeneratedRepoJSON returns the JSON for the newly generated repo.
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies. func newGeneratedRepoJSON(name string) string {
type fakeTemplateServer struct { 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}`,
mu sync.Mutex name, name, name, name)
files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool
puts []string
deletes []string
putBodies map[string]string // path -> decoded written content
repoGetsPost int // GET dest after generate (branch fallback)
} }
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer { func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}} c := gitea.NewClient(srvURL, "tok")
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
} }
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc { // TestCreateProjectHappyPath: all 6 files served and substituted.
return func(w http.ResponseWriter, r *http.Request) { func TestCreateProjectHappyPath(t *testing.T) {
f.mu.Lock() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
p := r.URL.Path
switch { switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl: // Template repo lookup
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true))) case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest: // Destination repo lookup — 404 means it doesn't exist yet
if !f.generated { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`)) _, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
f.repoGetsPost++
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate": // Generate
f.generated = true case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated) 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}`, _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
dest, dest, f.genBranch, dest, dest)
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"): // File contents GET — handle all 6 substitution files
var entries []string case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
for path := range f.files { filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-"))) _, _ = w.Write([]byte(fileContentsJSON(filePath)))
}
// include a tree (directory) entry to exercise the blob filter
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): // File contents PUT — handle all 6 substitution files
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
body, ok := f.files[path] filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
return
}
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
raw, _ := io.ReadAll(r.Body)
var args struct {
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &args)
dec, _ := base64.StdEncoding.DecodeString(args.Content)
f.puts = append(f.puts, path)
f.putBodies[path] = string(dec)
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} _, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
f.deletes = append(f.deletes, path)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
default: 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) w.WriteHeader(http.StatusNotFound)
} }
} }))
} defer srv.Close()
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate { tool := newCreateProjectTool(srv.URL)
return tools.NewCreateProjectFromTemplate( result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
}
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
t.Helper()
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
require.NoError(t, err) 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"` FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"` FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"` PartialFailure string `json:"partial_failure,omitempty"`
} }
require.NoError(t, json.Unmarshal(result, &out))
// Happy path: whole-tree substitution, content + path rename, correct module host. assert.Equal(t, "mathias/new-svc", out.FullName)
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) { assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
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, "main", out.DefaultBranch) assert.Equal(t, "main", out.DefaultBranch)
assert.Empty(t, out.PartialFailure) assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
// content-substituted files present; untouched file absent
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
// path rename recorded as "old -> new"
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
// rename: new path written, old path deleted
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
f.putBodies["cmd/new-svc/main.go"])
// the untouched file was never written
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
}
// The /generate response omits default_branch (the live gitea behavior the old
// mock hid) → tool must re-fetch the repo and still substitute.
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
assert.Empty(t, out.PartialFailure) assert.Empty(t, out.PartialFailure)
} }
// Fail loud: a template whose files carry no placeholders yields nothing // TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
// substituted — surface it rather than returning silent success. // server-configured default, so the same binary can generate from template-go-web or
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) { // template-go-agent without restart.
files := map[string]string{"README.md": "# static, no placeholders\n"} func TestCreateProjectTemplateNameOverride(t *testing.T) {
f := newFakeTemplateServer(files, "main") var templateLookups, generateCalls []string
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
assert.Empty(t, out.FilesSubstituted)
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
}
// Write failure mid-pass → partial_failure populated, no Go error.
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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()
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")
}
// 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")
}
})
}
}
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
func TestCreateProject_NameRegexFailure(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestCreateProject_AllowlistRejects(t *testing.T) {
_, err := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist")
}
func TestCreateProject_NotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" { switch {
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false))) case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
return templateLookups = append(templateLookups, "template-go-agent")
} _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
templateLookups = append(templateLookups, "template-go-web")
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) _, _ = w.Write([]byte(`{"message":"not found"}`))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
require.Error(t, err) generateCalls = append(generateCalls, r.URL.Path)
assert.ErrorIs(t, err, gitea.ErrValidation) 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)))
func TestCreateProject_DestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/repos/mathias/template-go-agent":
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
case "/api/v1/repos/mathias/new-svc":
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
default: default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
})) }))
defer srv.Close() defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
// 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")
}
// 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)
}
// 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")
}
// 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")
// 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()
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)
}
// 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 {
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()
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict) assert.ErrorIs(t, err, gitea.ErrConflict)
} }
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
// Files that should succeed (index 0-2 in substitutionFileList).
successFiles := map[string]bool{
"go.mod": true,
"Taskfile.yml": true,
"Dockerfile": true,
}
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
failFile := ".gitea/workflows/cd.yml"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
if filePath == failFile {
// Simulate upstream 500.
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
return
}
if !successFiles[filePath] {
t.Errorf("unexpected PUT for file: %s", filePath)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
// Best-effort: no Go error returned, partial state in result.
require.NoError(t, err)
var out struct {
FullName string `json:"full_name"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
// First 3 files should be in FilesSubstituted.
assert.Len(t, out.FilesSubstituted, 3)
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
assert.NotContains(t, out.FilesSubstituted, failFile)
// partial_failure should be non-empty.
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
}
@@ -1,22 +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). It should name the real remaining
// work — substituting the placeholders — so the caller isn't sent to a dead end.
func TestInfra179FinalizeMessage(t *testing.T) {
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
for _, want := range []string{"infra#179", "__PROJECT_NAME__", "__MODULE_PATH__", "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)
}
}
+7 -7
View File
@@ -4,9 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type DirList struct { type DirList struct {
@@ -26,18 +26,18 @@ func (t *DirList) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"path":{"type":"string"}, "path":{"type":"string"},
"ref":{"type":"string"} "ref":{"type":"string"}
}, },
"required":["owner","repo"] "required":["owner","name"]
}`), }`),
} }
} }
type dirListArgs struct { type dirListArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Ref string `json:"ref"` Ref string `json:"ref"`
} }
@@ -51,7 +51,7 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+7 -7
View File
@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type FileDelete struct { type FileDelete struct {
@@ -27,20 +27,20 @@ func (t *FileDelete) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"path":{"type":"string"}, "path":{"type":"string"},
"branch":{"type":"string"}, "branch":{"type":"string"},
"message":{"type":"string"}, "message":{"type":"string"},
"sha":{"type":"string"} "sha":{"type":"string"}
}, },
"required":["owner","repo","path","branch","message","sha"] "required":["owner","name","path","branch","message","sha"]
}`), }`),
} }
} }
type fileDeleteArgs struct { type fileDeleteArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Branch string `json:"branch"` Branch string `json:"branch"`
Message string `json:"message"` Message string `json:"message"`
@@ -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) 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, Branch: args.Branch,
Message: args.Message, Message: args.Message,
Sha: args.Sha, Sha: args.Sha,
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+8 -8
View File
@@ -6,9 +6,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
const fileReadMaxBytes = 1 << 20 // 1 MiB const fileReadMaxBytes = 1 << 20 // 1 MiB
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"path":{"type":"string"}, "path":{"type":"string"},
"ref":{"type":"string"} "ref":{"type":"string"}
}, },
"required":["owner","repo","path"] "required":["owner","name","path"]
}`), }`),
} }
} }
type fileReadArgs struct { type fileReadArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Ref string `json:"ref"` Ref string `json:"ref"`
} }
@@ -58,13 +58,13 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
ref := args.Ref ref := args.Ref
if ref == "" { if ref == "" {
var err error 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+3 -18
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -57,21 +57,6 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
assert.Equal(t, "main", result["ref"]) 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) { func TestFileReadAllowlistRejects(t *testing.T) {
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) 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"}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
+11 -11
View File
@@ -6,9 +6,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type FileWriteBranch struct { type FileWriteBranch struct {
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor { func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "file_write_branch", 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(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"path":{"type":"string"}, "path":{"type":"string"},
"content":{"type":"string"}, "content":{"type":"string"},
"branch":{"type":"string"}, "branch":{"type":"string"},
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
"message":{"type":"string"}, "message":{"type":"string"},
"sha":{"type":"string"} "sha":{"type":"string"}
}, },
"required":["owner","repo","path","content","branch","message"] "required":["owner","name","path","content","branch","message"]
}`), }`),
} }
} }
type fileWriteBranchArgs struct { type fileWriteBranchArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`
Branch string `json:"branch"` Branch string `json:"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 // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
base := args.Base base := args.Base
if base == "" { if base == "" {
var err error 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 { if err != nil {
return nil, err 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 return nil, err
} }
} }
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content)) 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, Branch: args.Branch,
Content: encoded, Content: encoded,
Message: args.Message, Message: args.Message,
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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(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)
}
+9 -9
View File
@@ -5,11 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity" "gitea.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/registry"
) )
type IssueComment struct { type IssueComment struct {
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"number":{"type":"integer","minimum":1}, "number":{"type":"integer","minimum":1},
"body":{"type":"string"} "body":{"type":"string"}
}, },
"required":["owner","repo","number","body"] "required":["owner","name","number","body"]
}`), }`),
} }
} }
type issueCommentArgs struct { type issueCommentArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Number int `json:"number"` Number int `json:"number"`
Body string `json:"body"` Body string `json:"body"`
} }
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
} }
body := identity.ApplyFooter(args.Body, auth.Caller(ctx)) 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+9 -9
View File
@@ -5,11 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity" "gitea.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/registry"
) )
type IssueCreate struct { type IssueCreate struct {
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"title":{"type":"string"}, "title":{"type":"string"},
"body":{"type":"string"}, "body":{"type":"string"},
"labels":{"type":"array","items":{"type":"integer"}}, "labels":{"type":"array","items":{"type":"integer"}},
"assignees":{"type":"array","items":{"type":"string"}}, "assignees":{"type":"array","items":{"type":"string"}},
"milestone":{"type":"integer"} "milestone":{"type":"integer"}
}, },
"required":["owner","repo","title"] "required":["owner","name","title"]
}`), }`),
} }
} }
type issueCreateArgs struct { type issueCreateArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Labels []int64 `json:"labels"` Labels []int64 `json:"labels"`
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
} }
body := identity.ApplyFooter(args.Body, auth.Caller(ctx)) 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, Title: args.Title,
Body: body, Body: body,
Labels: args.Labels, Labels: args.Labels,
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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(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(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)
}
-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(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(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(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)
}
+9 -9
View File
@@ -5,11 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity" "gitea.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/registry"
) )
type PRComment struct { type PRComment struct {
@@ -29,18 +29,18 @@ func (t *PRComment) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"number":{"type":"integer","minimum":1}, "number":{"type":"integer","minimum":1},
"body":{"type":"string"} "body":{"type":"string"}
}, },
"required":["owner","repo","number","body"] "required":["owner","name","number","body"]
}`), }`),
} }
} }
type prCommentArgs struct { type prCommentArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Number int `json:"number"` Number int `json:"number"`
Body string `json:"body"` Body string `json:"body"`
} }
@@ -61,7 +61,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
} }
body := identity.ApplyFooter(args.Body, auth.Caller(ctx)) 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+9 -9
View File
@@ -5,11 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity" "gitea.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/registry"
) )
type PRCreate struct { type PRCreate struct {
@@ -29,21 +29,21 @@ func (t *PRCreate) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"title":{"type":"string"}, "title":{"type":"string"},
"body":{"type":"string"}, "body":{"type":"string"},
"head":{"type":"string"}, "head":{"type":"string"},
"base":{"type":"string"}, "base":{"type":"string"},
"draft":{"type":"boolean"} "draft":{"type":"boolean"}
}, },
"required":["owner","repo","title","head","base"] "required":["owner","name","title","head","base"]
}`), }`),
} }
} }
type prCreateArgs struct { type prCreateArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Head string `json:"head"` Head string `json:"head"`
@@ -68,7 +68,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
body := identity.ApplyFooter(args.Body, auth.Caller(ctx)) 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, Title: args.Title,
Body: body, Body: body,
Head: args.Head, Head: args.Head,
+5 -5
View File
@@ -9,10 +9,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.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/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -30,7 +30,7 @@ const prFixture = `{
func callerContext(user string) context.Context { func callerContext(user string) context.Context {
var capturedCtx context.Context var capturedCtx context.Context
h := auth.CallerMiddleware(nil, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedCtx = r.Context() capturedCtx = r.Context()
})) }))
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequest("POST", "/", nil)
+8 -8
View File
@@ -8,9 +8,9 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
const ( const (
@@ -35,17 +35,17 @@ func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"number":{"type":"integer","minimum":1} "number":{"type":"integer","minimum":1}
}, },
"required":["owner","repo","number"] "required":["owner","name","number"]
}`), }`),
} }
} }
type prFilesDiffArgs struct { type prFilesDiffArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Number int `json:"number"` Number int `json:"number"`
} }
@@ -70,12 +70,12 @@ func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation) return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
} }
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Repo, args.Number) files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Repo, args.Number) rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+7 -7
View File
@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type PRGet struct { type PRGet struct {
@@ -25,17 +25,17 @@ func (t *PRGet) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"number":{"type":"integer","minimum":1} "number":{"type":"integer","minimum":1}
}, },
"required":["owner","repo","number"] "required":["owner","name","number"]
}`), }`),
} }
} }
type prGetArgs struct { type prGetArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Number int `json:"number"` Number int `json:"number"`
} }
@@ -51,7 +51,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation) return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
} }
pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Repo, args.Number) pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Name, args.Number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+7 -7
View File
@@ -4,9 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type PRList struct { type PRList struct {
@@ -26,20 +26,20 @@ func (t *PRList) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"state":{"type":"string","enum":["open","closed","all"]}, "state":{"type":"string","enum":["open","closed","all"]},
"head":{"type":"string"}, "head":{"type":"string"},
"page":{"type":"integer","minimum":1}, "page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50} "limit":{"type":"integer","minimum":1,"maximum":50}
}, },
"required":["owner","repo"] "required":["owner","name"]
}`), }`),
} }
} }
type prListArgs struct { type prListArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
State string `json:"state"` State string `json:"state"`
Head string `json:"head"` Head string `json:"head"`
Page int `json:"page"` Page int `json:"page"`
@@ -59,7 +59,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
state = "open" state = "open"
} }
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Repo, state, args.Head, args.Page, capLimit(args.Limit, 30)) prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
+11 -11
View File
@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
type PRMerge struct { type PRMerge struct {
@@ -27,21 +27,21 @@ func (t *PRMerge) Descriptor() registry.ToolDescriptor {
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "name":{"type":"string"},
"number":{"type":"integer","minimum":1}, "index":{"type":"integer","minimum":1},
"style":{"type":"string","enum":["merge","squash","rebase"]}, "style":{"type":"string","enum":["merge","squash","rebase"]},
"merge_message_title":{"type":"string"}, "merge_message_title":{"type":"string"},
"merge_message_field":{"type":"string"} "merge_message_field":{"type":"string"}
}, },
"required":["owner","repo","number"] "required":["owner","name","index"]
}`), }`),
} }
} }
type prMergeArgs struct { type prMergeArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Name string `json:"name"`
Number int `json:"number"` Index int `json:"index"`
Style string `json:"style"` Style string `json:"style"`
Title string `json:"merge_message_title"` Title string `json:"merge_message_title"`
Body string `json:"merge_message_field"` Body string `json:"merge_message_field"`
@@ -55,8 +55,8 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := t.a.Check(args.Owner); err != nil { if err := t.a.Check(args.Owner); err != nil {
return nil, err return nil, err
} }
if args.Number < 1 { if args.Index < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation) return nil, fmt.Errorf("index must be >= 1: %w", gitea.ErrValidation)
} }
style := args.Style style := args.Style
@@ -64,7 +64,7 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
style = "merge" style = "merge"
} }
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, args.Number, gitea.MergePRArgs{ if err := t.c.MergePullRequest(ctx, args.Owner, args.Name, args.Index, gitea.MergePRArgs{
Do: style, Do: style,
Title: args.Title, Title: args.Title,
Body: args.Body, Body: args.Body,
+3 -27
View File
@@ -8,9 +8,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.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/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -63,30 +63,6 @@ func TestPRMergeConflictReturnsError(t *testing.T) {
assert.ErrorIs(t, err, gitea.ErrConflict) assert.ErrorIs(t, err, gitea.ErrConflict)
} }
// #45: pr_merge advertises the canonical `number` (was `index`); `index` stays
// an accepted alias via the shim.
func TestPRMergeNumberCanonical(t *testing.T) {
sch := string(tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor().InputSchema)
assert.Contains(t, sch, `"number":`, "pr_merge must advertise number")
assert.NotContains(t, sch, `"index":`, "pr_merge must not advertise index")
for _, args := range []string{
`{"owner":"owner","repo":"repo","number":7}`,
`{"owner":"owner","repo":"repo","index":7}`,
} {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(args))
require.NoError(t, err, args)
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", gotPath, args)
srv.Close()
}
}
func TestPRMergeAllowlistRejects(t *testing.T) { func TestPRMergeAllowlistRejects(t *testing.T) {
tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`))
-60
View File
@@ -1,60 +0,0 @@
package tools
import (
"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"
)
// RegisterAll registers every gitea-mcp tool on reg. main.go and the
// dispatch round-trip test share this single list so a newly added tool
// cannot be wired in one place but missing from the other.
//
// giteaBaseURL is needed by workflow_run_trigger; tmplOwner/tmplRepo seed
// create_project_from_template's default source template.
func RegisterAll(
reg *registry.Registry,
c *gitea.Client,
a *allowlist.Allowlist,
giteaBaseURL, tmplOwner, tmplRepo string,
) {
reg.Register(NewRepoList(c, a))
reg.Register(NewRepoGet(c, a))
reg.Register(NewRepoSearch(c, a))
reg.Register(NewRepoStatus(c, a))
reg.Register(NewFileRead(c, a))
reg.Register(NewFileWriteBranch(c, a))
reg.Register(NewFileDelete(c, a))
reg.Register(NewDirList(c, a))
reg.Register(NewBranchList(c, a))
reg.Register(NewBranchDelete(c, a))
reg.Register(NewBranchProtectionGet(c, a))
reg.Register(NewPRCreate(c, a))
reg.Register(NewPRGet(c, a))
reg.Register(NewPRList(c, a))
reg.Register(NewPRMerge(c, a))
reg.Register(NewTBDShip(c, a))
reg.Register(NewPRComment(c, a))
reg.Register(NewPRFilesDiff(c, a))
reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL))
reg.Register(NewWorkflowRunStatus(c, a))
reg.Register(NewCodeSearch(c, a))
reg.Register(NewIssueCreate(c, a))
reg.Register(NewIssueEdit(c, a))
reg.Register(NewIssueComment(c, a))
reg.Register(NewCreateProjectFromTemplate(c, a, tmplOwner, tmplRepo))
reg.Register(NewTagCreate(c, a))
reg.Register(NewRepoCreate(c, a))
reg.Register(NewRepoUpdate(c, a))
reg.Register(NewRepoMirrorPush(c, a))
reg.Register(NewRepoTree(c, a))
reg.Register(NewRepoTopicsUpdate(c, a))
reg.Register(NewIssueGet(c, a))
reg.Register(NewIssueList(c, a))
reg.Register(NewIssueListComments(c, a))
reg.Register(NewIssueClose(c, a))
reg.Register(NewIssueReopen(c, a))
reg.Register(NewWorkflowRunList(c, a))
reg.Register(NewReleaseCreate(c, a))
reg.Register(NewRepoDelete(c, a))
}
-58
View File
@@ -1,58 +0,0 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"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/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildRegistry wires the full tool set exactly as main.go does, against an
// unroutable gitea base URL so any handler that reaches the network fails fast
// (connection refused) rather than hanging.
func buildRegistry() *registry.Registry {
reg := registry.New()
c := gitea.NewClient("http://127.0.0.1:1", "")
a := allowlist.New([]string{"mathias"})
tools.RegisterAll(reg, c, a, "http://127.0.0.1:1", "mathias", "template-go-web")
return reg
}
// Every registered tool must be dispatchable and advertise a parseable input
// schema. This is the regression guard for #36's whole class: a tool that is
// wired up but unroutable (or ships a malformed schema) fails CI here instead
// of 404ing a live caller.
func TestEveryRegisteredToolIsDispatchable(t *testing.T) {
reg := buildRegistry()
descs := reg.Tools()
require.NotEmpty(t, descs)
for _, d := range descs {
t.Run(d.Name, func(t *testing.T) {
require.NotEmpty(t, d.Name, "tool has empty name")
assert.True(t, json.Valid(d.InputSchema),
"tool %q ships invalid JSON input schema", d.Name)
// Dispatch with empty args. We do not care whether the call
// succeeds (most fail allowlist/validation/network) — only that
// the name resolves to a handler. ErrToolNotFound here means the
// tool advertised a name the dispatcher cannot route.
_, err := reg.Dispatch(context.Background(), d.Name, json.RawMessage(`{}`))
assert.False(t, errors.Is(err, registry.ErrToolNotFound),
"registered tool %q does not dispatch", d.Name)
})
}
}
// Lock the tool count so an accidental drop of a registration in RegisterAll
// (the single source main.go and this test share) fails loudly.
func TestRegisteredToolCount(t *testing.T) {
assert.Len(t, buildRegistry().Tools(), 38)
}
-73
View File
@@ -1,73 +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 ReleaseCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
return &ReleaseCreate{c: c, a: a}
}
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "release_create",
Description: "Create a release (and tag if it doesn't exist) for a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
"release_name":{"type":"string","description":"Display name for the release."},
"body":{"type":"string","description":"Release notes / changelog."},
"draft":{"type":"boolean"},
"prerelease":{"type":"boolean"},
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
},
"required":["owner","repo","tag_name"]
}`),
}
}
type releaseCreateArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
TagName string `json:"tag_name"`
ReleaseName string `json:"release_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Target string `json:"target"`
}
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args releaseCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
TagName: args.TagName,
Name: args.ReleaseName,
Body: args.Body,
Draft: args.Draft,
Prerelease: args.Prerelease,
Target: args.Target,
})
if err != nil {
return nil, err
}
return textOK(rel)
}
-38
View File
@@ -1,38 +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 TestReleaseCreateTool(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":"changelog","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()
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
assert.Contains(t, string(out), `"html_url"`)
}
func TestReleaseCreateAllowlistRejects(t *testing.T) {
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
require.Error(t, err)
}

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