58 Commits

Author SHA1 Message Date
a69d3a8b76 Merge pull request 'feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18)' (#22) from feat/repo-ux into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 08:24:35 +00:00
Mathias Bergqvist
5f3ad99122 feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18)
All checks were successful
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
repo_tree: GET /git/trees/{ref}?recursive=1 — full recursive file tree
repo_topics_update: PUT /repos/{owner}/{repo}/topics — replace topic list
file_read: detect array response and return descriptive error for dir paths
2026-05-15 10:23:31 +02:00
2c94de7b59 Merge pull request 'feat(tools): repo_create, repo_update, repo_mirror_push (#12, #13, #16)' (#21) from feat/repo-crud into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
2026-05-15 08:21:23 +00:00
Mathias Bergqvist
e2da495581 feat(tools): add repo_create, repo_update, repo_mirror_push (#12, #13, #16)
All checks were successful
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
repo_create: POST /user/repos or /orgs/{org}/repos, is_org flag routes
repo_update: PATCH /repos/{owner}/{repo}, confirm required when private=false
repo_mirror_push: add/list/delete push mirrors, password never returned
2026-05-15 10:14:18 +02:00
Mathias Bergqvist
7178ae32be chore: re-sync context adapters 2026-05-15 09:53:09 +02:00
cb4f0caf0b docs: add current sprint context for gitea-mcp v0.2 tools (#11-#19)
All checks were successful
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 3s
2026-05-14 21:27:29 +00:00
Mathias
174669b9f6 fix(mcp): drop strict session-id requirement on POST /mcp
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 48s
The claude.ai connector's MCP transport proxy does not reliably
propagate the Mcp-Session-Id header issued during initialize. With the
previous strict gate (return 400 plain text "missing or invalid
Mcp-Session-Id"), every tools/list and tools/call from claude.ai
failed and the Anthropic proxy surfaced it as:

  Streamable HTTP error: {"jsonrpc":"2.0","id":N,"error":
    {"code":-32600,"message":"Anthropic Proxy: Invalid content from server"}}

— because the plain-text 400 response is not valid JSON-RPC.

All tools the gitea-mcp server exposes are stateless single-shot
calls, so there is no functional reason to gate them on a session.
brain-mcp and supervisor-mcp don't gate either, and claude.ai works
against them fine. Match that behavior: keep issuing Mcp-Session-Id
on initialize for clients that want to use it, but stop rejecting
calls that don't send one back.

Test renamed PostWithoutSessionRejected → PostWithoutSessionAccepted
and updated to assert the tools/list response shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:58:13 +02:00
Mathias Bergqvist
7a53935a9e chore(mcp): remove supervisor MCP entry
Some checks failed
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Failing after 1m3s
2026-05-12 14:49:35 +02:00
Mathias
3795800461 fix(auth): require Bearer on /mcp regardless of DefaultToken
All checks were successful
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 4s
Previously BearerMiddleware allowed requests with no Authorization
header to pass through whenever GITEA_MCP_DEFAULT_TOKEN was set. The
intent was "fall back to the service PAT for upstream Gitea calls,"
but the side effect was that anyone could hit /mcp anonymously and the
server would happily proxy requests as the service account.

Drop that path. Auth on /mcp now requires either:
  - a valid Dex-issued JWT, or
  - a Bearer matching GITEA_MCP_STATIC_TOKEN.

The Gitea service PAT (GITEA_MCP_DEFAULT_TOKEN) is no longer wired
into BearerMiddleware at all — it stays an upstream-client concern,
used by gitea.NewClient for outbound API calls only. This decouples
"can this caller invoke a tool" from "what credentials does the tool
use against Gitea".

Tests updated: drop the NoAuthHeader_WithDefault permissive case, add
NoAuthHeader_RejectsEvenWhenStaticConfigured to lock in the new
behavior.

Closes part of mathias/infra#2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:44:38 +02:00
Mathias Bergqvist
9987522f1a fix(ci): skip context sync drift check in CI environment
All checks were successful
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Successful in 4s
Context-sync walks up the directory tree to find the root AGENT.md.
On koala's act_runner, checkout is under /var/lib/act_runner/, not
under ~/dev/, so ROOT_CONTEXT resolves to empty. Generated files
differ from committed files (which include root context), causing
the drift check to fail.

Skip context sync when CI=true; local checks still verify sync.
2026-05-12 12:09:24 +02:00
Mathias Bergqvist
0e53738d9f ci: retrigger after deps fix
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
2026-05-12 11:40:53 +02:00
Mathias Bergqvist
91be18c100 feat(auth): JWT-or-static middleware + /.well-known/oauth-protected-resource (issue #5)
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
- internal/auth/jwt.go: JWTValidator via lestrrat-go/jwx/v2, JWKS auto-refresh
- internal/auth/bearer.go: replace Gitea PAT validation with JWT->static->default chain
- internal/gitea/client.go: always use service PAT; remove TokenFromContext lookup
- internal/config/config.go: add DexIssuerURL, MCPAudience, MCPResourceURL, StaticToken
- cmd/gitea-mcp/main.go: wire validator, fix /.well-known to return real AS list
- bearer_test.go: rewrite for new API
2026-05-12 11:30:52 +02:00
Mathias Bergqvist
efbbd37882 chore: remove debug request logging
All checks were successful
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 11s
CD / Deploy via GitOps (push) Successful in 2s
Root cause confirmed (claude.ai sends no auth header); fallback token
is in place. Logging no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:30:08 +02:00
Mathias Bergqvist
9d08352324 feat(auth): fall back to GITEA_MCP_DEFAULT_TOKEN when no Bearer header
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 11s
CD / Deploy via GitOps (push) Successful in 3s
claude.ai connectors call the server with no Authorization header (confirmed
via request logging). Add a configurable default Gitea PAT so unauthenticated
clients (like claude.ai) can still reach the server.

Claude Code continues to pass per-request PATs; defaultToken="" preserves
the existing strict behaviour when the env var is unset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:22:04 +02:00
Mathias Bergqvist
70173875d8 debug: add request logging to diagnose claude.ai connector auth
All checks were successful
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 3s
Logs method, path, origin, has_auth, user_agent per request so we can
see exactly what claude.ai sends. Temporary; remove once root cause found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:41:50 +02:00
Mathias Bergqvist
3784bcc31b fix(lint): check resp.Body.Close error in bearer_test.go
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 15s
CD / Deploy via GitOps (push) Successful in 2s
Silences errcheck violations that have been breaking CI since the test
was written.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:25:37 +02:00
Mathias Bergqvist
f63605bdd0 fix(mcp): downgrade protocolVersion to 2025-03-26
Some checks failed
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Claude Code CLI rejects 2025-06-18 and silently drops the connection;
2025-03-26 is the highest version it supports. Fixes #4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 07:53:18 +02:00
Mathias Bergqvist
c4d3735272 fix(mcp): allow GET/SSE without session ID for claude.ai compatibility
Some checks failed
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
2026-05-07 23:22:21 +02:00
Mathias Bergqvist
d8db786e27 ci: add environment: staging gate to deploy job
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Aligns with cobalt-dingo reference — the deploy job was missing the
Gitea Actions environment protection so staging approvals/secrets were
not enforced.
2026-05-07 21:52:40 +02:00
Mathias Bergqvist
923689afa5 feat: replace static API token with per-request Gitea PAT pass-through
Callers now supply their own Gitea PAT as a Bearer token; the server validates
it against GET /api/v1/user and threads it through context to all downstream
Gitea API calls. GITEA_API_TOKEN env var and the GiteaAPIToken config field are
removed.
2026-05-07 21:04:47 +02:00
Mathias Bergqvist
9a5d0005c5 feat: add 9 GitOps agent tools for full GitOps loop
All checks were successful
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 11s
CD / Deploy via GitOps (push) Has been skipped
Adds branch_list, branch_delete, branch_protection_get, pr_list,
pr_merge, dir_list, file_delete, tag_create, and repo_status so an
AI agent can autonomously drive feature-branch or trunk-based
development workflows against Gitea.
2026-05-07 08:11:45 +02:00
Mathias Bergqvist
c0576359d7 feat: register 9 new GitOps tools in main
Wires branch_list, branch_delete, branch_protection_get, pr_list,
pr_merge, dir_list, file_delete, tag_create, and repo_status into the
MCP server registry so they are discoverable and callable by agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:00:29 +02:00
Mathias Bergqvist
0c5903a196 feat(tools): repo_status 2026-05-06 22:59:51 +02:00
Mathias Bergqvist
839fc93dcd feat(tools): tag_create 2026-05-06 22:54:22 +02:00
Mathias Bergqvist
5dac4856bd feat(tools): file_delete 2026-05-06 22:51:21 +02:00
Mathias Bergqvist
0eb9ebcafd feat(tools): dir_list 2026-05-06 22:49:50 +02:00
Mathias Bergqvist
284d5e19f6 feat(tools): pr_merge 2026-05-06 22:48:02 +02:00
Mathias Bergqvist
388131c8cd feat(tools): pr_list 2026-05-06 22:46:11 +02:00
Mathias Bergqvist
ddfcc32afd feat(tools): branch_protection_get 2026-05-06 22:44:24 +02:00
Mathias Bergqvist
9e4251c1a7 feat(tools): branch_delete 2026-05-06 22:42:38 +02:00
Mathias Bergqvist
06882d185e fix(tools): branch_list schema constraints 2026-05-06 22:41:05 +02:00
Mathias Bergqvist
073d88b29a feat(tools): branch_list 2026-05-06 22:38:15 +02:00
Mathias Bergqvist
44c42fa636 feat(gitea): add DeleteJSONBody for delete-with-body requests 2026-05-06 22:36:37 +02:00
Mathias Bergqvist
e7bd954e90 docs: add GitOps agent tools implementation plan
11 tasks covering 9 new tools, client methods, tests, and registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:22:41 +02:00
Mathias Bergqvist
0cd465fb68 docs: add GitOps agent tools design spec
9 new tools to enable full autonomous GitOps loop: repo_status,
branch_list/delete/protection_get, pr_list/merge, dir_list,
file_delete, tag_create.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:51:39 +02:00
4f0f65e26a Merge pull request 'fix: add OAuth discovery endpoints for claude.ai handshake' (#3) from fix/oauth-discovery-endpoints into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 3s
Reviewed-on: #3
2026-05-06 15:20:58 +00:00
Mathias Bergqvist
9cbb564cd9 fix: add OAuth discovery endpoints for claude.ai handshake
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 5s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
Implements RFC 9728 protected resource metadata and HEAD probe so
claude.ai can complete its pre-handshake discovery without hitting 404.

- GET /.well-known/oauth-protected-resource → 200 {"authorization_servers":[]}
- GET /.well-known/oauth-authorization-server → 404 (no auth server)
- HEAD /mcp → 200 + MCP-Protocol-Version: 2025-06-18 header

Closes #2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:19:14 +02:00
47e631da23 Merge pull request 'fix(file_write_branch): support file creation by routing POST/PUT on sha' (#1) from fix/file-write-branch-create into main
All checks were successful
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 3s
Reviewed-on: #1
2026-05-06 14:44:38 +00:00
d35ff9781c test(file_write_branch): assert branch and commit_sha on PUT path for parity
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 5s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
2026-05-06 14:35:20 +00:00
052827320a test(file_write_branch): cover POST-on-create and PUT-on-update routing
All checks were successful
CD / Lint / Test / Vet (pull_request) Successful in 6s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
2026-05-06 14:05:23 +00:00
c85197ea5e fix(files): route UpsertFile to POST when sha is empty so new files can be created 2026-05-06 14:04:36 +00:00
Mathias Bergqvist
c345025221 fix(lint): staticcheck S1030, QF1002 and remove unused _ctx stub
All checks were successful
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
2026-05-05 09:02:39 +02:00
Mathias Bergqvist
64559f0250 fix(lint): check Body.Close error return in http client
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
2026-05-05 08:55:31 +02:00
Mathias Bergqvist
b8463d66a0 chore: drop environment: staging (no-op for solo homelab)
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
2026-05-05 08:51:17 +02:00
Mathias Bergqvist
d261a9f3fe feat: gitea actions cd.yml (cobalt-dingo pattern) 2026-05-05 07:52:45 +02:00
Mathias Bergqvist
09b3f25211 chore: add check task for CI 2026-05-05 07:52:39 +02:00
Mathias Bergqvist
a8d04242d9 feat: dockerfile 2026-05-05 07:52:29 +02:00
Mathias Bergqvist
1f9934349b chore(tools): centralize pagination cap helper 2026-05-04 23:06:38 +02:00
Mathias Bergqvist
4274b48ea5 feat(gitea): default-branch lru cache
Shared LRU avoids repeated Gitea calls for default-branch resolution;
the simple stdlib map alternative would race on concurrent access without
a mutex per entry, which is more code than the LRU.
2026-05-04 23:06:06 +02:00
Mathias Bergqvist
fb473262ba feat(gitea): read retry once on 5xx GET 2026-05-04 23:04:55 +02:00
Mathias Bergqvist
39dc22ec3a feat(tools): create_project_from_template
Generates a new repo from mathias/template-go-web via Gitea's generate
API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders
in six known files (best-effort, partial failure surfaced in result).

Validates name regex, allowlist, template flag, and destination
non-existence before generating. Adds Template field to gitea.Repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 23:02:21 +02:00
Mathias Bergqvist
e95e87e8e3 feat(tools): pr_files_diff with caps
Returns per-file unified diff for a PR, capped at 20KB/file and 200KB
total response. Files exceeding per-file cap report truncated+omitted_lines;
files that would push the response over 200KB go to omitted_files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:57:11 +02:00
Mathias Bergqvist
d3d0fed6b1 feat(tools): pr_comment with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:53:14 +02:00
Mathias Bergqvist
c8a353aa35 feat(tools): issue_comment with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:52:24 +02:00
Mathias Bergqvist
6f43ff216f feat(tools): issue_create with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:51:40 +02:00
Mathias Bergqvist
2c6b9986e4 feat(tools): code_search (org-wide fan-out)
When repo is omitted, lists owner's repos then concurrently searches
each one (semaphore cap 5, 5s per-repo timeout). Merges and sorts
hits by score desc with deterministic tiebreak. Partial failures
tracked in partial_repos without aborting the whole fan-out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:48:39 +02:00
Mathias Bergqvist
e4a9d058f0 feat(tools): code_search (single-repo)
Adds SearchCode to gitea.Client and code_search MCP tool for single-repo
code search via GET /api/v1/repos/{owner}/{repo}/search?type=code.
Fan-out placeholder returns ErrValidation (lands in 7.3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:47:33 +02:00
Mathias Bergqvist
61cce37ff5 feat(tools): repo_search with allowlist post-filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:44:44 +02:00
93 changed files with 10266 additions and 52 deletions

2
.aider.conf.yml Normal file
View File

@@ -0,0 +1,2 @@
read: .aider.conventions.md
auto-commits: false

318
.aider.conventions.md Normal file
View File

@@ -0,0 +1,318 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
the same repo, create a branch (`agent/<description>`), commit there, and open a
PR. Do not merge without explicit instruction from Mathias.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
When available, agents can query the shared knowledge base:
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project`
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed

143
.context/PROJECT.md Normal file
View File

@@ -0,0 +1,143 @@
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project`
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed

22
.context/mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"brain": {
"type": "http",
"url": "https://brain-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"gitea": {
"type": "http",
"url": "https://git-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
}
},
"infra": {
"type": "http",
"url": "https://infra-mcp.d-ma.be/mcp"
}
}
}

325
.context/system-prompt.txt Normal file
View File

@@ -0,0 +1,325 @@
You are a coding assistant working on a specific project.
Follow all conventions from both the root agent context and project context.
---
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
the same repo, create a branch (`agent/<description>`), commit there, and open a
PR. Do not merge without explicit instruction from Mathias.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
When available, agents can query the shared knowledge base:
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project` —
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
---

321
.cursorrules Normal file
View File

@@ -0,0 +1,321 @@
# Cursor rules — auto-generated
# Do not edit. Run: task context:sync
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
the same repo, create a branch (`agent/<description>`), commit there, and open a
PR. Do not merge without explicit instruction from Mathias.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
When available, agents can query the shared knowledge base:
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project` —
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed

186
.gitea/workflows/cd.yml Normal file
View File

@@ -0,0 +1,186 @@
name: CD
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
env:
IMAGE: gitea-mcp
jobs:
# ── 1. Quality gate ─────────────────────────────────────────────────────────
check:
name: Lint / Test / Vet
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # self-hosted runner: Go cache persists on disk between runs
- name: Verify toolchain
run: |
go version
task --version
govulncheck -version 2>&1 || true
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
golangci-lint --version
- name: Run checks
run: task check
# ── 2. Build image ──────────────────────────────────────────────────────────
build:
name: Build & Import
needs: check
runs-on: self-hosted
if: github.event_name != 'pull_request'
outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }}
steps:
- uses: actions/checkout@v4
- name: Derive image tags
id: meta
run: |
SHA=$(git rev-parse --short HEAD)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push to local registry
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
buildah build \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REF} \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "✓ Image pushed to ${REF}"
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
echo "$OUTPUT" | grep -q "gitea-mcp" \
&& echo "✓ Smoke test passed" \
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
deploy:
name: Deploy via GitOps
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- name: Update image tag in infra repo
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
rm -rf /tmp/infra
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
cd /tmp/infra
DEPLOYMENT="k3s/apps/gitea-mcp/deployment.yaml"
sed -i "s|image: localhost:5000/gitea-mcp:.*|image: localhost:5000/gitea-mcp:${IMAGE_TAG}|" "$DEPLOYMENT"
grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" "$DEPLOYMENT" \
|| { echo "✗ image tag patch failed"; exit 1; }
if git diff --quiet "$DEPLOYMENT"; then
echo " image tag unchanged — skipping push"
else
git -c user.name="gitea-mcp CI" \
-c user.email="ci@gitea-mcp.local" \
commit -m "chore(deploy): gitea-mcp → ${IMAGE_TAG}" "$DEPLOYMENT"
git push origin main
echo "✓ pushed to infra repo"
fi
shred -u ~/.ssh/id_infra
- name: Trigger Flux reconcile (immediate)
run: |
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
EXPECTED="localhost:5000/gitea-mcp:${IMAGE_TAG}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy gitea-mcp -n gitea-mcp \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied new image after ${i}s"
break
fi
sleep 1
done
kubectl get deploy gitea-mcp -n gitea-mcp \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
- name: Verify rollout
run: |
kubectl rollout status deployment/gitea-mcp \
--namespace gitea-mcp \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n gitea-mcp -o wide
echo "── events ──"
kubectl get events -n gitea-mcp --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n gitea-mcp -l app=gitea-mcp | tail -40
exit 1
}
- name: Confirm pod running new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
kubectl get pods -n gitea-mcp \
-l app=gitea-mcp \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].spec.containers[0].image}' \
| grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" \
&& echo "✓ pod running new image" \
|| { echo "✗ pod image mismatch"; exit 1; }

View File

@@ -0,0 +1,42 @@
---
name: go-patterns
description: Go project patterns — endpoint checklist, error handling, HTMX responses, dependency policy. Use when writing Go code, adding endpoints, or reviewing Go PRs.
---
# Go project patterns
## New endpoint checklist
1. Define request/response types in `types.go`
2. Write handler in `handlers.go` using `http.HandlerFunc`
3. Add route in `routes.go`
4. Write table-driven test in `handlers_test.go`
5. Run `task check` before committing
## Error handling pattern
```go
if err != nil {
return fmt.Errorf("descriptiveOperation: %w", err)
}
```
Never log and return — do one or the other.
## HTMX response pattern
```go
func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) {
items, err := h.store.List(r.Context())
if err != nil {
http.Error(w, "failed to list items", http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
h.templates.Render(w, "items/_list", items)
return
}
h.templates.Render(w, "items/index", items)
}
```
## Dependency policy
- Prefer stdlib: `net/http`, `encoding/json`, `database/sql`
- Allowed without justification: `testify`, `slog`, `templ`, `sqlc`
- Needs justification in commit message: anything else

View File

@@ -0,0 +1,31 @@
---
name: htmx-patterns
description: HTMX conventions — default attributes, form patterns, validation errors, hypermedia-first API design. Use when writing HTMX templates or Go handlers that return HTML fragments.
---
# HTMX patterns
## Default attributes
Always include on interactive elements:
- `hx-indicator` for loading states
- `hx-swap="innerHTML"` as default (explicit over implicit)
- `hx-target` pointing to a specific ID, never `this` in production
## Form pattern
```html
<form hx-post="/items" hx-target="#item-list" hx-swap="beforeend" hx-indicator="#spinner">
<input type="text" name="title" required>
<button type="submit">Add</button>
<span id="spinner" class="htmx-indicator">...</span>
</form>
```
## Server-sent validation errors
Return 422 with the error fragment, swap into the form's error container:
```html
hx-target-422="#form-errors"
```
## Prefer hypermedia over JSON
If the endpoint returns data for display, return an HTML fragment.
Only use JSON for machine-to-machine APIs or when a non-browser client needs it.

318
AGENTS.md Normal file
View File

@@ -0,0 +1,318 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
the same repo, create a branch (`agent/<description>`), commit there, and open a
PR. Do not merge without explicit instruction from Mathias.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
- **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
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base
When available, agents can query the shared knowledge base:
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
name once hyperguild is deployed. Until then, agents that try to
reach the knowledge service on a host where it isn't running will
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project`
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed

143
CLAUDE.md Normal file
View File

@@ -0,0 +1,143 @@
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current sprint — gitea-mcp v0.2 (2026-05-14)
### Context
This sprint implements new MCP tools needed for `hyperguild new-project`
the automated project creation flow triggered from claude.ai. See brain knowledge
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
for full background.
### Issues to implement (priority order)
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
| Issue | Tool | Gitea API |
|-------|------|-----------|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
**Batch 3 — can wait**
| Issue | Tool | Note |
|-------|------|------|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
### How to add a tool (pattern)
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
Key rules:
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
- Use `textOK(result)` for success output
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
### Token permissions needed
New tools require these additional Gitea token scopes:
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
- `delete_repo` — repo_delete
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
If scopes are missing, update token in Gitea settings before running tests.
### Definition of done
- `task check` passes (all tools, all batches)
- Each new tool manually callable via `claude mcp call`
- PR #1 (batch 1) merged before starting batch 2
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gitea-mcp /gitea-mcp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/gitea-mcp"]

View File

@@ -14,3 +14,42 @@ tasks:
lint: lint:
desc: Run golangci-lint desc: Run golangci-lint
cmds: [golangci-lint run ./...] cmds: [golangci-lint run ./...]
vet:
cmds:
- go vet ./...
- govulncheck ./... || true
check:
desc: Run all checks (context freshness + lint + test + vet)
cmds:
- cmd: |
if [ -n "${CI:-}" ]; then
echo "✓ context sync: skipped in CI"
else
bash scripts/context-sync.sh
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
if [ -n "$drift" ]; then
echo "ERROR: derived adapters drifted from canonical context." >&2
echo "$drift" >&2
echo "" >&2
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
echo " git commit -m 'chore: re-sync context adapters'" >&2
exit 1
fi
echo "✓ context: canonical and adapters are in sync"
fi
- task: lint
- task: test
- task: vet
context:sync:
desc: Regenerate all harness-specific context files
cmds:
- bash scripts/context-sync.sh
context:sync:claude:
cmds: [bash scripts/context-sync.sh claude]
context:sync:agents:
cmds: [bash scripts/context-sync.sh agents]
context:sync:cursor:
cmds: [bash scripts/context-sync.sh cursor]

View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"context"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -23,18 +25,46 @@ func main() {
os.Exit(1) os.Exit(1)
} }
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken) ctx := context.Background()
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
if err != nil {
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
}
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners) ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New() reg := registry.New()
reg.Register(tools.NewRepoList(giteaClient, ownerAllow)) reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow)) reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
reg.Register(tools.NewFileRead(giteaClient, ownerAllow)) reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
reg.Register(tools.NewFileWriteBranch(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.NewPRCreate(giteaClient, ownerAllow))
reg.Register(tools.NewPRGet(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.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
mcpSrv := mcp.NewServer(mcp.ServerOptions{ mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg, Registry: reg,
@@ -42,11 +72,30 @@ func main() {
}) })
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(auth.CallerMiddleware(mcpSrv))) mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
auth.CallerMiddleware(mcpSrv),
),
))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
}) })
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
payload := map[string]any{
"resource": cfg.MCPResourceURL,
"authorization_servers": []string{},
}
if cfg.DexIssuerURL != "" {
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
}
_ = json.NewEncoder(w).Encode(payload)
})
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0") logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
# GitOps Agent Tools — Design Spec
**Date:** 2026-05-06
**Status:** Approved
## Goal
Extend the Gitea MCP server with the tools an AI agent needs to drive a full GitOps development loop autonomously — reading repo state, deciding on a branching strategy, making changes, opening and merging PRs, and tagging releases — without any local git tooling.
The agent selects between feature-branch and trunk-based development based on branch protection rules it reads at runtime.
---
## New Tools (9)
All tools follow the existing pattern: one file in `internal/tools/`, one Gitea client method in `internal/gitea/`, allowlist check on `owner`, table-driven tests in both packages.
### `repo_status`
Convenience read tool — returns branch list, open PRs, and protection info for a target branch in a single call. Designed for the agent's first query on any repo so it can decide its strategy.
**Inputs:** `owner`, `name`, `branch` (optional — defaults to repo default branch)
**Output:** `{ branches: [...], open_prs: [...], protection: { protected, required_approvals, push_whitelist, merge_whitelist } }`
**Implementation:** calls `ListBranches` + `ListPullRequests(state=open)` + `GetBranchProtection` internally, composes result. No new Gitea API surface.
---
### `branch_list`
**Inputs:** `owner`, `name`, `page` (optional), `limit` (optional, default 30)
**Output:** array of `{ name, sha }`
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/branches`
---
### `branch_delete`
**Inputs:** `owner`, `name`, `branch`
**Output:** confirmation message
**Gitea endpoint:** `DELETE /api/v1/repos/{owner}/{repo}/branches/{branch}`
**Error handling:** 403 from Gitea (protected branch) surfaced as a descriptive error.
---
### `branch_protection_get`
**Inputs:** `owner`, `name`, `branch`
**Output:** `{ protected, required_approvals, push_whitelist, merge_whitelist }`
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/branch_protections/{branch}`
**Error handling:** 404 → return `{ protected: false }`, not an error. Allows agent to make clean boolean decisions.
---
### `pr_list`
**Inputs:** `owner`, `name`, `state` (`open`/`closed`/`all`, default `open`), `head` (optional branch filter), `page`, `limit`
**Output:** array of `{ number, title, state, head_branch, base_branch, draft, html_url }`
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/pulls`
---
### `pr_merge`
**Inputs:** `owner`, `name`, `index`, `style` (`merge`/`squash`/`rebase`, default `merge`), `merge_message_title` (optional), `merge_message_field` (optional)
**Output:** `{ merged: true, commit_sha }` — if Gitea returns 204 No Content (some merge styles), output is `{ merged: true }` without `commit_sha`.
**Gitea endpoint:** `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge`
**Error handling:** 405 (checks failing) and 409 (merge conflict) passed through with the Gitea error message intact so the agent understands why it failed.
---
### `dir_list`
**Inputs:** `owner`, `name`, `path` (empty string = repo root), `ref` (optional branch/tag/SHA)
**Output:** array of `{ name, path, type (file|dir|symlink), sha, size }`
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/contents/{path}`
**Note:** same endpoint as `file_read` but returns an array when `path` is a directory. Client detects response shape (array vs object). If called on a file path, returns a descriptive error: `"path is a file, not a directory — use file_read"`.
---
### `file_delete`
**Inputs:** `owner`, `name`, `path`, `branch`, `message`, `sha` (required — current blob SHA)
**Output:** `{ commit_sha, html_url }`
**Gitea endpoint:** `DELETE /api/v1/repos/{owner}/{repo}/contents/{path}`
---
### `tag_create`
**Inputs:** `owner`, `name`, `tag` (tag name), `target` (branch name or commit SHA), `message` (optional — creates annotated tag if set)
**Output:** `{ tag, commit_sha, html_url }`
**Gitea endpoint:** `POST /api/v1/repos/{owner}/{repo}/tags`
---
## Gitea Client Methods
New methods on `gitea.Client`:
| Method | Endpoint | HTTP verb |
|--------|----------|-----------|
| `ListBranches(ctx, owner, repo, page, limit)` | `/api/v1/repos/{owner}/{repo}/branches` | GET |
| `DeleteBranch(ctx, owner, repo, branch)` | `/api/v1/repos/{owner}/{repo}/branches/{branch}` | DELETE |
| `GetBranchProtection(ctx, owner, repo, branch)` | `/api/v1/repos/{owner}/{repo}/branch_protections/{branch}` | GET |
| `ListPullRequests(ctx, owner, repo, state, head, page, limit)` | `/api/v1/repos/{owner}/{repo}/pulls` | GET |
| `MergePullRequest(ctx, owner, repo, index, args)` | `/api/v1/repos/{owner}/{repo}/pulls/{index}/merge` | POST |
| `ListContents(ctx, owner, repo, path, ref)` | `/api/v1/repos/{owner}/{repo}/contents/{path}` | GET |
| `DeleteFile(ctx, owner, repo, path, args)` | `/api/v1/repos/{owner}/{repo}/contents/{path}` | DELETE |
| `CreateTag(ctx, owner, repo, args)` | `/api/v1/repos/{owner}/{repo}/tags` | POST |
---
## Architecture
No structural changes. Each new tool is:
- One file: `internal/tools/<tool_name>.go` + `internal/tools/<tool_name>_test.go`
- One client method: `internal/gitea/<domain>.go` (added to existing domain files where logical)
- Registered in `cmd/gitea-mcp/main.go`
`repo_status` is the only tool with internal composition — it calls three client methods and merges their results. It has no dedicated client method of its own.
New client methods go in existing domain files:
- Branch methods → `internal/gitea/files.go` (already has `BranchExists`, `CreateBranch`)
- PR methods → `internal/gitea/pulls.go`
- Contents (dir_list, file_delete) → `internal/gitea/files.go`
- Tags → new `internal/gitea/tags.go`
---
## Testing
Pattern: table-driven tests with a `httptest.NewServer` mock, same as `file_write_branch_test.go`.
Each tool covers:
- Happy path
- 404 response
- Allowlist rejection
- Tool-specific edge cases:
- `branch_delete`: 403 protected branch
- `branch_protection_get`: 404 → `{protected: false}` not error
- `dir_list`: file path → descriptive error
- `pr_merge`: 405 checks failing, 409 merge conflict
- `repo_status`: any one sub-call failing propagates the error
---
## Agent Decision Flow (Reference)
```
1. repo_status(owner, name)
→ if branch.protected && required_approvals > 0:
use feature-branch workflow
→ else:
use trunk-based workflow
Feature-branch workflow:
file_write_branch (auto-creates branch)
→ pr_create
→ [wait for CI via workflow_run_status]
→ pr_merge
→ branch_delete
Trunk-based workflow:
file_write_branch(branch=main)
→ [optionally] tag_create
Post-merge (either):
→ [optionally] tag_create to trigger deployment
```

17
go.mod
View File

@@ -2,9 +2,24 @@ module gitea.d-ma.be/mathias/gitea-mcp
go 1.26.2 go 1.26.2
require (
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
)
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/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/stretchr/testify v1.11.1 // indirect github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sys v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

30
go.sum
View File

@@ -1,9 +1,39 @@
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=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

42
internal/auth/bearer.go Normal file
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)
})
}

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)
}

79
internal/auth/jwt.go Normal file
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
}

View File

@@ -8,18 +8,26 @@ import (
type Config struct { type Config struct {
Port string // GITEA_MCP_PORT, default 8080 Port string // GITEA_MCP_PORT, default 8080
GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be
GiteaAPIToken string // GITEA_API_TOKEN — bot user token DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, service PAT; used by Gitea client for all upstream calls
StaticToken string // GITEA_MCP_STATIC_TOKEN, optional static bearer for service-to-service auth
AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias" AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias"
OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
} }
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
Port: envOr("GITEA_MCP_PORT", "8080"), Port: envOr("GITEA_MCP_PORT", "8080"),
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"), GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"), DefaultToken: os.Getenv("GITEA_MCP_DEFAULT_TOKEN"),
StaticToken: os.Getenv("GITEA_MCP_STATIC_TOKEN"),
AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")), AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")),
OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")), OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")),
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
MCPAudience: os.Getenv("MCP_AUDIENCE"),
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
} }
return cfg, nil return cfg, nil
} }

View File

@@ -10,7 +10,6 @@ import (
func TestLoadDefaults(t *testing.T) { func TestLoadDefaults(t *testing.T) {
t.Setenv("GITEA_BASE_URL", "") t.Setenv("GITEA_BASE_URL", "")
t.Setenv("GITEA_API_TOKEN", "")
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "") t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "") t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
t.Setenv("GITEA_MCP_PORT", "") t.Setenv("GITEA_MCP_PORT", "")
@@ -23,7 +22,6 @@ func TestLoadDefaults(t *testing.T) {
func TestLoadFromEnv(t *testing.T) { func TestLoadFromEnv(t *testing.T) {
t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be") t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be")
t.Setenv("GITEA_API_TOKEN", "test-token")
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme") t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme")
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com") t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
t.Setenv("GITEA_MCP_PORT", "9000") t.Setenv("GITEA_MCP_PORT", "9000")
@@ -31,7 +29,6 @@ func TestLoadFromEnv(t *testing.T) {
cfg, err := config.Load() cfg, err := config.Load()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL) assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL)
assert.Equal(t, "test-token", cfg.GiteaAPIToken)
assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners) assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners)
assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist) assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
assert.Equal(t, "9000", cfg.Port) assert.Equal(t, "9000", cfg.Port)

View File

@@ -6,23 +6,41 @@ import (
"io" "io"
"net/http" "net/http"
"time" "time"
"github.com/hashicorp/golang-lru/v2/expirable"
) )
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
hc *http.Client hc *http.Client
branchCache *expirable.LRU[string, string]
} }
func NewClient(baseURL, token string) *Client { func NewClient(baseURL, token string) *Client {
return &Client{ return &Client{
baseURL: baseURL, baseURL: baseURL,
token: token, token: token,
hc: &http.Client{Timeout: 30 * time.Second}, hc: &http.Client{Timeout: 30 * time.Second},
branchCache: expirable.NewLRU[string, string](64, nil, 60*time.Second),
} }
} }
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { // DefaultBranch returns the default branch for a repo. Cached for 60s.
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
key := owner + "/" + name
if v, ok := c.branchCache.Get(key); ok {
return v, nil
}
repo, err := c.GetRepo(ctx, owner, name)
if err != nil {
return "", err
}
c.branchCache.Add(key, repo.DefaultBranch)
return repo.DefaultBranch, nil
}
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
var reader io.Reader var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
@@ -31,8 +49,9 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if c.token != "" { token := c.token
req.Header.Set("Authorization", "token "+c.token) if token != "" {
req.Header.Set("Authorization", "token "+token)
} }
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -43,11 +62,20 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err return b, resp.StatusCode, err
} }
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
b, status, err := c.doOnce(ctx, method, path, body)
if err == nil && method == http.MethodGet && status >= 500 && status < 600 {
time.Sleep(250 * time.Millisecond)
return c.doOnce(ctx, method, path, body)
}
return b, status, err
}
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) { func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
return c.do(ctx, http.MethodGet, path, nil) return c.do(ctx, http.MethodGet, path, nil)
} }
@@ -68,6 +96,10 @@ func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, erro
return c.do(ctx, http.MethodDelete, path, nil) return c.do(ctx, http.MethodDelete, path, nil)
} }
func (c *Client) DeleteJSONBody(ctx context.Context, path string, body []byte) ([]byte, int, error) {
return c.do(ctx, http.MethodDelete, path, body)
}
type rawResponse struct { type rawResponse struct {
Body []byte Body []byte
Status int Status int
@@ -83,8 +115,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
if err != nil { if err != nil {
return nil, err return nil, err
} }
if c.token != "" { token := c.token
req.Header.Set("Authorization", "token "+c.token) if token != "" {
req.Header.Set("Authorization", "token "+token)
} }
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -95,7 +128,7 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic"
"testing" "testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -27,3 +28,37 @@ func TestClientGetsTokenInHeader(t *testing.T) {
assert.Contains(t, string(body), `"ok":true`) assert.Contains(t, string(body), `"ok":true`)
assert.Equal(t, "token test-token", gotAuth) assert.Equal(t, "token test-token", gotAuth)
} }
func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
http.Error(w, "boom", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
body, status, err := c.GetJSON(context.Background(), "/api/v1/test")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Contains(t, string(body), `"ok":true`)
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
}
func TestRetryOnPostNotRetried(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&attempts, 1)
http.Error(w, "boom", http.StatusServiceUnavailable)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, _, _ = c.PostJSON(context.Background(), "/api/v1/test", []byte(`{}`))
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "POST should not retry")
}

View File

@@ -0,0 +1,43 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
type CodeSearchHit struct {
Path string `json:"path"`
Snippet string `json:"snippet"`
HTMLURL string `json:"html_url"`
Score float64 `json:"score,omitempty"`
}
type codeSearchEnvelope struct {
Data []CodeSearchHit `json:"data"`
OK bool `json:"ok"`
}
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
owner, repo, url.QueryEscape(q), page, limit)
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var env codeSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data, nil
}

View File

@@ -0,0 +1,39 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/code_search.go",
"snippet":"func (c *Client) SearchCode",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
"score":2.5
}],
"ok":true
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
require.Len(t, hits, 1)
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
) )
type FileContents struct { type FileContents struct {
@@ -26,6 +27,10 @@ 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
@@ -92,13 +97,138 @@ type FileWriteResult struct {
} `json:"commit"` } `json:"commit"`
} }
func (c *Client) UpsertFile(ctx context.Context, owner, repo, path string, args UpsertFileArgs) (*FileWriteResult, error) { func (c *Client) ListBranches(ctx context.Context, owner, repo string, page, limit int) ([]Branch, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches?page=%d&limit=%d", owner, repo, page, limit)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var branches []Branch
if err := json.Unmarshal(body, &branches); err != nil {
return nil, err
}
return branches, nil
}
func (c *Client) DeleteBranch(ctx context.Context, owner, repo, branch string) error {
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", owner, repo, branch)
body, status, err := c.DeleteJSON(ctx, p)
if err != nil {
return err
}
return MapStatus(status, body)
}
type BranchProtection struct {
Protected bool `json:"-"`
RequiredApprovals int64 `json:"required_approvals"`
PushWhitelist []string `json:"push_whitelist_usernames"`
MergeWhitelist []string `json:"merge_whitelist_usernames"`
}
func (c *Client) GetBranchProtection(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, branch)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if status == 404 {
return &BranchProtection{Protected: false}, nil
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var bp BranchProtection
if err := json.Unmarshal(body, &bp); err != nil {
return nil, err
}
bp.Protected = true
return &bp, nil
}
type DirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Sha string `json:"sha"`
Size int64 `json:"size"`
}
func (c *Client) ListContents(ctx context.Context, owner, repo, path, ref string) ([]DirEntry, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
if ref != "" {
p += "?ref=" + url.QueryEscape(ref)
}
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
if len(body) > 0 && body[0] == '{' {
return nil, fmt.Errorf("path is a file, not a directory — use file_read: %w", ErrValidation)
}
var entries []DirEntry
if err := json.Unmarshal(body, &entries); err != nil {
return nil, err
}
return entries, nil
}
type DeleteFileArgs struct {
Branch string `json:"branch"`
Message string `json:"message"`
Sha string `json:"sha"`
}
func (c *Client) DeleteFile(ctx context.Context, owner, repo, path string, args DeleteFileArgs) (*FileWriteResult, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
payload, err := json.Marshal(args) payload, err := json.Marshal(args)
if err != nil { if err != nil {
return nil, err return nil, err
} }
body, status, err := c.PutJSON(ctx, p, payload) body, status, err := c.DeleteJSONBody(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var out FileWriteResult
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpsertFile creates a file when args.Sha is empty (POST) or updates an existing
// file when args.Sha is set (PUT). Gitea routes both operations by HTTP method on
// the same /contents/{path} URL, and rejects PUT without a sha.
func (c *Client) UpsertFile(ctx context.Context, owner, repo, path string, args UpsertFileArgs) (*FileWriteResult, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
var (
body []byte
status int
)
if args.Sha == "" {
body, status, err = c.PostJSON(ctx, p, payload)
} else {
body, status, err = c.PutJSON(ctx, p, payload)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -82,6 +82,28 @@ func TestCreateBranchSendsPayload(t *testing.T) {
assert.Equal(t, "main", payload["old_branch_name"]) assert.Equal(t, "main", payload["old_branch_name"])
} }
func TestListBranches(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branches", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("page"))
assert.Equal(t, "30", r.URL.Query().Get("limit"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"name":"main","commit":{"id":"abc","url":"http://example.com"}},
{"name":"feat/x","commit":{"id":"def","url":"http://example.com"}}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
branches, err := c.ListBranches(context.Background(), "o", "r", 0, 0)
require.NoError(t, err)
require.Len(t, branches, 2)
assert.Equal(t, "main", branches[0].Name)
assert.Equal(t, "abc", branches[0].Commit.ID)
assert.Equal(t, "feat/x", branches[1].Name)
}
func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) { func TestUpsertFileSendsPayloadAndDecodesResult(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) {
@@ -116,3 +138,130 @@ func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) {
assert.Equal(t, "http://example.com/p.md", result.Content.HTMLURL) assert.Equal(t, "http://example.com/p.md", result.Content.HTMLURL)
assert.Equal(t, "abc", result.Commit.Sha) assert.Equal(t, "abc", result.Commit.Sha)
} }
func TestDeleteBranch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branches/feat/x", r.URL.Path)
assert.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DeleteBranch(context.Background(), "o", "r", "feat/x")
require.NoError(t, err)
}
func TestDeleteBranchProtected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"branch is protected"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.DeleteBranch(context.Background(), "o", "r", "main")
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrPermissionDenied)
}
func TestGetBranchProtectionFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branch_protections/main", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"required_approvals": 2,
"push_whitelist_usernames": ["alice"],
"merge_whitelist_usernames": ["bob"]
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
bp, err := c.GetBranchProtection(context.Background(), "o", "r", "main")
require.NoError(t, err)
assert.True(t, bp.Protected)
assert.Equal(t, int64(2), bp.RequiredApprovals)
assert.Equal(t, []string{"alice"}, bp.PushWhitelist)
assert.Equal(t, []string{"bob"}, bp.MergeWhitelist)
}
func TestGetBranchProtectionNotFoundReturnsUnprotected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
bp, err := c.GetBranchProtection(context.Background(), "o", "r", "feat/x")
require.NoError(t, err)
assert.False(t, bp.Protected)
}
func TestListContentsDirectory(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/contents/src", r.URL.Path)
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":100},
{"name":"lib","path":"src/lib","type":"dir","sha":"def","size":0}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
entries, err := c.ListContents(context.Background(), "o", "r", "src", "main")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "main.go", entries[0].Name)
assert.Equal(t, "file", entries[0].Type)
assert.Equal(t, "lib", entries[1].Name)
assert.Equal(t, "dir", entries[1].Type)
}
func TestListContentsOnFileReturnsError(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(`{"path":"main.go","sha":"abc","size":100,"content":"","encoding":"base64"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListContents(context.Background(), "o", "r", "main.go", "")
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestDeleteFile(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/contents/src/old.go", r.URL.Path)
assert.Equal(t, http.MethodDelete, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"content":null,
"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
result, err := c.DeleteFile(context.Background(), "o", "r", "src/old.go", gitea.DeleteFileArgs{
Branch: "main",
Message: "remove old.go",
Sha: "blobsha",
})
require.NoError(t, err)
assert.Equal(t, "cmt1", result.Commit.Sha)
var payload map[string]string
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "main", payload["branch"])
assert.Equal(t, "remove old.go", payload["message"])
assert.Equal(t, "blobsha", payload["sha"])
}

71
internal/gitea/issues.go Normal file
View File

@@ -0,0 +1,71 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
}
type CreateIssueArgs struct {
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Milestone int64 `json:"milestone,omitempty"`
}
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
type IssueComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
}
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
// works for both issues and pull requests (PRs share index space with issues).
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, index int, body string) (*IssueComment, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, err
}
respBody, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, respBody); err != nil {
return nil, err
}
var c2 IssueComment
if err := json.Unmarshal(respBody, &c2); err != nil {
return nil, err
}
return &c2, nil
}

View File

@@ -0,0 +1,72 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateIssue(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"number":42,"title":"x","body":"y","html_url":"http://example.com/issues/42","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
iss, err := c.CreateIssue(context.Background(), "o", "r", gitea.CreateIssueArgs{
Title: "x",
Body: "y",
})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "x", payload["title"])
assert.Equal(t, "y", payload["body"])
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "x", iss.Title)
assert.Equal(t, "y", iss.Body)
assert.Equal(t, "http://example.com/issues/42", iss.HTMLURL)
assert.Equal(t, "open", iss.State)
}
func TestCreateIssueComment(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comment, err := c.CreateIssueComment(context.Background(), "o", "r", 42, "hello")
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "hello", payload["body"])
assert.Equal(t, int64(7), comment.ID)
assert.Equal(t, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
}

71
internal/gitea/mirrors.go Normal file
View File

@@ -0,0 +1,71 @@
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)
}

View File

@@ -0,0 +1,64 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"gitea.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)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
) )
type PullRequest struct { type PullRequest struct {
@@ -64,3 +65,85 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, index i
} }
return &pr, nil return &pr, nil
} }
type PullRequestFile struct {
Filename string `json:"filename"`
Status string `json:"status"` // added | modified | deleted | renamed
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, index int) ([]PullRequestFile, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", 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 files []PullRequestFile
if err := json.Unmarshal(body, &files); err != nil {
return nil, err
}
return files, nil
}
// GetPullRequestDiff returns the raw unified diff. The endpoint serves text/plain, not JSON,
// so we use doRaw to bypass the json Accept header expectation.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, index int) ([]byte, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d.diff", owner, repo, index)
resp, err := c.doRaw(ctx, "GET", p, nil)
if err != nil {
return nil, err
}
if err := MapStatus(resp.Status, resp.Body); err != nil {
return nil, err
}
return resp.Body, nil
}
type MergePRArgs struct {
Do string `json:"Do"`
Title string `json:"merge_message_title,omitempty"`
Body string `json:"merge_message_field,omitempty"`
}
func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, index int, args MergePRArgs) error {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)
payload, err := json.Marshal(args)
if err != nil {
return err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return err
}
return MapStatus(status, body)
}
func (c *Client) ListPullRequests(ctx context.Context, owner, repo, state, head string, page, limit int) ([]PullRequest, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=%s&page=%d&limit=%d",
owner, repo, url.QueryEscape(state), page, limit)
if head != "" {
p += "&head=" + url.QueryEscape(head)
}
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var prs []PullRequest
if err := json.Unmarshal(body, &prs); err != nil {
return nil, err
}
return prs, nil
}

View File

@@ -93,3 +93,98 @@ func TestGetPullRequest(t *testing.T) {
assert.Equal(t, "open", pr.State) assert.Equal(t, "open", pr.State)
assert.True(t, pr.Draft) assert.True(t, pr.Draft)
} }
func TestGetPullRequestFiles(t *testing.T) {
filesJSON := `[
{"filename":"main.go","status":"modified","additions":10,"deletions":5},
{"filename":"README.md","status":"added","additions":20,"deletions":0}
]`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42/files", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(filesJSON))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
files, err := c.GetPullRequestFiles(context.Background(), "o", "r", 42)
require.NoError(t, err)
require.Len(t, files, 2)
assert.Equal(t, "main.go", files[0].Filename)
assert.Equal(t, "modified", files[0].Status)
assert.Equal(t, 10, files[0].Additions)
assert.Equal(t, 5, files[0].Deletions)
assert.Equal(t, "README.md", files[1].Filename)
assert.Equal(t, "added", files[1].Status)
assert.Equal(t, 20, files[1].Additions)
assert.Equal(t, 0, files[1].Deletions)
}
func TestGetPullRequestDiff(t *testing.T) {
rawDiff := "diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1,2 +1,3 @@\n+package main\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42.diff", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(rawDiff))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
diff, err := c.GetPullRequestDiff(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Equal(t, []byte(rawDiff), diff)
}
func TestListPullRequests(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path)
assert.Equal(t, "open", r.URL.Query().Get("state"))
assert.Equal(t, "feat/x", r.URL.Query().Get("head"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[` + pullFixture + `]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
prs, err := c.ListPullRequests(context.Background(), "o", "r", "open", "feat/x", 0, 0)
require.NoError(t, err)
require.Len(t, prs, 1)
assert.Equal(t, 7, prs[0].Number)
assert.Equal(t, "feat/x", prs[0].Head.Ref)
}
func TestMergePullRequestSuccess(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/7/merge", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "squash"})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "squash", payload["Do"])
}
func TestMergePullRequestConflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "merge"})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
) )
type Repo struct { type Repo struct {
@@ -14,6 +15,57 @@ type Repo struct {
Private bool `json:"private"` Private bool `json:"private"`
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
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
}
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) {
@@ -38,6 +90,101 @@ func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) (
return repos, nil return repos, nil
} }
type repoSearchEnvelope struct {
Data []Repo `json:"data"`
OK bool `json:"ok"`
}
func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit int) ([]Repo, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/search?q=%s&page=%d&limit=%d",
url.QueryEscape(q), page, limit)
if owner != "" {
path += "&owner=" + url.QueryEscape(owner)
}
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var env repoSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
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"`
}
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)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic"
"testing" "testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -11,6 +12,23 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestSearchRepos(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
assert.Equal(t, "infra", r.URL.Query().Get("q"))
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
repos, err := c.SearchRepos(context.Background(), "infra", "mathias", 1, 30)
require.NoError(t, err)
require.Len(t, repos, 1)
assert.Equal(t, "mathias/infra", repos[0].FullName)
}
func TestListRepos(t *testing.T) { func TestListRepos(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) {
assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path) assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path)
@@ -28,3 +46,112 @@ func TestListRepos(t *testing.T) {
assert.Equal(t, "mathias/infra", repos[0].FullName) assert.Equal(t, "mathias/infra", repos[0].FullName)
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 TestDefaultBranchCachesAcrossCalls(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"infra","full_name":"o/infra","default_branch":"trunk"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
for i := 0; i < 5; i++ {
b, err := c.DefaultBranch(context.Background(), "o", "infra")
require.NoError(t, err)
assert.Equal(t, "trunk", b)
}
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "5 calls should cause exactly 1 server hit due to cache")
}

42
internal/gitea/tags.go Normal file
View File

@@ -0,0 +1,42 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type CreateTagArgs struct {
TagName string `json:"tag_name"`
Target string `json:"target"`
Message string `json:"message,omitempty"`
}
type Tag struct {
Name string `json:"name"`
ID string `json:"id"`
Message string `json:"message"`
Commit struct {
Sha string `json:"sha"`
} `json:"commit"`
}
func (c *Client) CreateTag(ctx context.Context, owner, repo string, args CreateTagArgs) (*Tag, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/tags", owner, repo)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var tag Tag
if err := json.Unmarshal(body, &tag); err != nil {
return nil, err
}
return &tag, nil
}

View File

@@ -0,0 +1,49 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateTag(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/tags", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"name":"v1.0.0",
"id":"tagsha",
"message":"release",
"commit":{"sha":"cmt1","url":"http://example.com/commit/cmt1"}
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
tag, err := c.CreateTag(context.Background(), "o", "r", gitea.CreateTagArgs{
TagName: "v1.0.0",
Target: "main",
Message: "release",
})
require.NoError(t, err)
assert.Equal(t, "v1.0.0", tag.Name)
assert.Equal(t, "cmt1", tag.Commit.Sha)
var payload map[string]string
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "v1.0.0", payload["tag_name"])
assert.Equal(t, "main", payload["target"])
assert.Equal(t, "release", payload["message"])
}

View File

@@ -0,0 +1,72 @@
package gitea
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate.
type GenerateFromTemplateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private"`
DefaultBranch string `json:"default_branch,omitempty"`
GitContent bool `json:"git_content"` // include all template files
}
// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate.
func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
return &r, nil
}
// SubstituteFile reads a file from the given branch, applies string replacements,
// and writes it back if any changes were made. Best-effort — returns a wrapped error
// that includes the file path.
func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error {
fc, err := c.GetFileContents(ctx, owner, repo, path, branch)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
content := string(decoded)
for k, v := range replacements {
content = strings.ReplaceAll(content, k, v)
}
if content == string(decoded) {
return nil // no changes, skip write
}
encoded := base64.StdEncoding.EncodeToString([]byte(content))
_, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{
Branch: branch,
Content: encoded,
Message: "Apply template substitutions",
Sha: fc.Sha,
})
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,156 @@
package gitea_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateFromTemplate(t *testing.T) {
var capturedBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
capturedBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"name":"new-svc",
"full_name":"mathias/new-svc",
"default_branch":"main",
"description":"A new service",
"private":true,
"clone_url":"http://gitea.example.com/mathias/new-svc.git",
"html_url":"http://gitea.example.com/mathias/new-svc",
"template":false
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{
Owner: "mathias",
Name: "new-svc",
Description: "A new service",
Private: true,
GitContent: true,
})
require.NoError(t, err)
// Verify the captured POST body contains the expected fields.
var payload map[string]any
require.NoError(t, json.Unmarshal(capturedBody, &payload))
assert.Equal(t, "mathias", payload["owner"])
assert.Equal(t, "new-svc", payload["name"])
assert.Equal(t, "A new service", payload["description"])
assert.Equal(t, true, payload["private"])
assert.Equal(t, true, payload["git_content"])
// Verify the decoded repo fields.
assert.Equal(t, "new-svc", repo.Name)
assert.Equal(t, "mathias/new-svc", repo.FullName)
assert.Equal(t, "main", repo.DefaultBranch)
assert.Equal(t, "A new service", repo.Description)
assert.True(t, repo.Private)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL)
}
func TestSubstituteFileApplies(t *testing.T) {
originalContent := "module __MODULE_PATH__\n\ngo 1.22\n"
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
var capturedPutBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`))
case http.MethodPut:
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
var err error
capturedPutBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`))
default:
t.Errorf("unexpected method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.NoError(t, err)
// Verify the PUT body contains the substituted content.
require.NotNil(t, capturedPutBody, "PUT should have been called")
var payload map[string]string
require.NoError(t, json.Unmarshal(capturedPutBody, &payload))
decoded, err := base64.StdEncoding.DecodeString(payload["content"])
require.NoError(t, err)
assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc")
assert.NotContains(t, string(decoded), "__MODULE_PATH__")
assert.Equal(t, "abc123", payload["sha"])
assert.Equal(t, "Apply template substitutions", payload["message"])
}
func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) {
originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n"
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
var putCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`))
case http.MethodPut:
putCount.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`))
}
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
// Replacements that don't match anything in the content.
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.NoError(t, err)
assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged")
}
func TestSubstituteFileReadError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"file not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err)
}

View File

@@ -9,7 +9,7 @@ import (
) )
const ( const (
ProtocolVersion = "2025-06-18" ProtocolVersion = "2025-03-26"
maxRequestBodyBytes = 1 << 20 // 1 MiB maxRequestBodyBytes = 1 << 20 // 1 MiB
) )
@@ -31,6 +31,9 @@ func NewServer(opts ServerOptions) *Server {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodHead:
w.Header().Set("MCP-Protocol-Version", ProtocolVersion)
w.WriteHeader(http.StatusOK)
case http.MethodGet: case http.MethodGet:
s.handleGET(w, r) s.handleGET(w, r)
case http.MethodPost: case http.MethodPost:
@@ -53,7 +56,6 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
return return
} }
// initialize is the only method allowed without a session.
if req.Method == "initialize" { if req.Method == "initialize" {
sid := s.opts.Sessions.Issue() sid := s.opts.Sessions.Issue()
w.Header().Set("Mcp-Session-Id", sid) w.Header().Set("Mcp-Session-Id", sid)
@@ -65,11 +67,12 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
return return
} }
sid := r.Header.Get("Mcp-Session-Id") // Mcp-Session-Id is advisory: we issue one on initialize and accept it back,
if !s.opts.Sessions.Valid(sid) { // but every tool the gitea-mcp server exposes is stateless single-shot, so
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest) // we do not gate non-initialize calls on it. The claude.ai connector's
return // transport proxy is observed to not propagate the session header reliably,
} // and the spec allows servers to be sessionless. Compare with brain-mcp /
// supervisor-mcp, which never required a session at all.
switch req.Method { switch req.Method {
case "tools/list": case "tools/list":
@@ -108,11 +111,8 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
sid := r.Header.Get("Mcp-Session-Id") // Session ID is optional for GET: clients may open the SSE stream before
if !s.opts.Sessions.Valid(sid) { // calling initialize (e.g. claude.ai probes on add). Accept with or without.
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")

View File

@@ -52,19 +52,27 @@ func TestInitialize(t *testing.T) {
var resp map[string]any var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
result := resp["result"].(map[string]any) result := resp["result"].(map[string]any)
assert.Equal(t, "2025-06-18", result["protocolVersion"]) assert.Equal(t, mcp.ProtocolVersion, result["protocolVersion"])
si := result["serverInfo"].(map[string]any) si := result["serverInfo"].(map[string]any)
assert.Equal(t, "gitea-mcp", si["name"]) assert.Equal(t, "gitea-mcp", si["name"])
} }
func TestPostWithoutSessionRejected(t *testing.T) { func TestPostWithoutSessionAccepted(t *testing.T) {
// gitea-mcp tools are stateless single-shot; Mcp-Session-Id is advisory.
// claude.ai's MCP transport proxy is observed to not propagate the
// session header reliably, so non-initialize calls must work without it.
srv := newServer(t) srv := newServer(t)
rr := postJSON(t, srv, map[string]any{ rr := postJSON(t, srv, map[string]any{
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 2, "id": 2,
"method": "tools/list", "method": "tools/list",
}, "") }, "")
require.Equal(t, http.StatusBadRequest, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
result := resp["result"].(map[string]any)
assert.Contains(t, result, "tools")
} }
func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) { func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {
@@ -118,6 +126,15 @@ func TestPostBodyTooLarge(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
} }
func TestHEADReturnsMCPProtocolVersionHeader(t *testing.T) {
srv := newServer(t)
req := httptest.NewRequest(http.MethodHead, "/mcp", nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, mcp.ProtocolVersion, rr.Header().Get("MCP-Protocol-Version"))
}
func TestToolsCallToolNotFound(t *testing.T) { func TestToolsCallToolNotFound(t *testing.T) {
srv := newServer(t) srv := newServer(t)
// Initialize to get a session ID. // Initialize to get a session ID.

View File

@@ -0,0 +1,64 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchDelete struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewBranchDelete(c *gitea.Client, a *allowlist.Allowlist) *BranchDelete {
return &BranchDelete{c: c, a: a}
}
func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "branch_delete",
Description: "Delete a branch from a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","name","branch"]
}`),
}
}
type branchDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Branch string `json:"branch"`
}
func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args branchDeleteArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
}
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
return nil, err
}
return textOK(map[string]any{
"deleted": true,
"branch": args.Branch,
})
}

View File

@@ -0,0 +1,51 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBranchDeleteSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, true, result["deleted"])
assert.Equal(t, "feat/x", result["branch"])
}
func TestBranchDeleteProtectedReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"branch is protected"}`))
}))
defer srv.Close()
tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrPermissionDenied)
}
func TestBranchDeleteAllowlistRejects(t *testing.T) {
tool := tools.NewBranchDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"feat/x"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,67 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewBranchList(c *gitea.Client, a *allowlist.Allowlist) *BranchList {
return &BranchList{c: c, a: a}
}
func (t *BranchList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "branch_list",
Description: "List branches in a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","name"]
}`),
}
}
type branchListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args branchListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
if err != nil {
return nil, err
}
result := make([]map[string]any, len(branches))
for i, b := range branches {
result[i] = map[string]any{
"name": b.Name,
"sha": b.Commit.ID,
}
}
return textOK(result)
}

View File

@@ -0,0 +1,43 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBranchListReturnsNames(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(`[
{"name":"main","commit":{"id":"abc","url":""}},
{"name":"feat/x","commit":{"id":"def","url":""}}
]`))
}))
defer srv.Close()
tool := tools.NewBranchList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
require.NoError(t, err)
var result []map[string]any
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result, 2)
assert.Equal(t, "main", result[0]["name"])
assert.Equal(t, "abc", result[0]["sha"])
assert.Equal(t, "feat/x", result[1]["name"])
}
func TestBranchListAllowlistRejects(t *testing.T) {
tool := tools.NewBranchList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,63 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type BranchProtectionGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewBranchProtectionGet(c *gitea.Client, a *allowlist.Allowlist) *BranchProtectionGet {
return &BranchProtectionGet{c: c, a: a}
}
func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "branch_protection_get",
Description: "Get branch protection rules. Returns {protected:false} if no rule exists — never returns an error for unprotected branches.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","name","branch"]
}`),
}
}
type branchProtectionGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Branch string `json:"branch"`
}
func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args branchProtectionGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"protected": bp.Protected,
"required_approvals": bp.RequiredApprovals,
"push_whitelist": bp.PushWhitelist,
"merge_whitelist": bp.MergeWhitelist,
})
}

View File

@@ -0,0 +1,54 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBranchProtectionGetProtected(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(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
}))
defer srv.Close()
tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, true, result["protected"])
assert.Equal(t, float64(1), result["required_approvals"])
}
func TestBranchProtectionGetUnprotected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, false, result["protected"])
}
func TestBranchProtectionGetAllowlistRejects(t *testing.T) {
tool := tools.NewBranchProtectionGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,189 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type semaphore chan struct{}
func newSem(n int) semaphore { return make(semaphore, n) }
func (s semaphore) acquire() { s <- struct{}{} }
func (s semaphore) release() { <-s }
type CodeSearch struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewCodeSearch(c *gitea.Client, a *allowlist.Allowlist) *CodeSearch {
return &CodeSearch{c: c, a: a}
}
func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "code_search",
Description: "Search code across one repo or fan out across an owner's repos.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"q":{"type":"string"},
"owner":{"type":"string"},
"repo":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["q","owner"]
}`),
}
}
type codeSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Page int `json:"page"`
Limit int `json:"limit"`
}
type codeSearchResult struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
HTMLURL string `json:"html_url"`
}
func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args codeSearchArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
args.Page = 1
}
args.Limit = capLimit(args.Limit, 30)
if args.Repo != "" {
return t.singleRepo(ctx, args)
}
return t.fanOut(ctx, args)
}
func (t *CodeSearch) singleRepo(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit)
if err != nil {
return nil, err
}
results := make([]codeSearchResult, 0, len(hits))
repoFull := args.Owner + "/" + args.Repo
for _, h := range hits {
score := h.Score
if score == 0 {
score = 1.0
}
results = append(results, codeSearchResult{
Repo: repoFull,
Path: h.Path,
Snippet: h.Snippet,
Score: score,
HTMLURL: h.HTMLURL,
})
}
out := map[string]any{"results": results}
if len(hits) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
func (t *CodeSearch) fanOut(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
repos, err := t.c.ListRepos(ctx, args.Owner, 1, 50)
if err != nil {
return nil, err
}
type repoResult struct {
repo string
hits []gitea.CodeSearchHit
err error
}
resultsCh := make(chan repoResult, len(repos))
sem := newSem(5)
var wg sync.WaitGroup
for _, r := range repos {
repo := r // capture
wg.Add(1)
go func() {
defer wg.Done()
sem.acquire()
defer sem.release()
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
hits, err := t.c.SearchCode(rctx, args.Owner, repo.Name, args.Q, 1, args.Limit)
resultsCh <- repoResult{repo: args.Owner + "/" + repo.Name, hits: hits, err: err}
}()
}
wg.Wait()
close(resultsCh)
merged := make([]codeSearchResult, 0)
var partialRepos []string
for rr := range resultsCh {
if rr.err != nil {
partialRepos = append(partialRepos, rr.repo)
continue
}
for _, h := range rr.hits {
score := h.Score
if score == 0 {
score = 1.0
}
merged = append(merged, codeSearchResult{
Repo: rr.repo, Path: h.Path, Snippet: h.Snippet, Score: score, HTMLURL: h.HTMLURL,
})
}
}
// Sort by score desc, then by repo+path for determinism.
sort.Slice(merged, func(i, j int) bool {
if merged[i].Score != merged[j].Score {
return merged[i].Score > merged[j].Score
}
if merged[i].Repo != merged[j].Repo {
return merged[i].Repo < merged[j].Repo
}
return merged[i].Path < merged[j].Path
})
if len(merged) > args.Limit {
merged = merged[:args.Limit]
}
out := map[string]any{
"results": merged,
"partial": len(partialRepos) > 0,
}
if len(partialRepos) > 0 {
sort.Strings(partialRepos)
out["partial_repos"] = partialRepos
}
return textOK(out)
}

View File

@@ -0,0 +1,187 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCodeSearchSingleRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/repos.go",
"snippet":"func (c *Client) ListRepos",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
"score":3.0
}],
"ok":true
}`))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
}
func TestCodeSearchAllowlistRejects(t *testing.T) {
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`))
require.Error(t, err)
}
func TestCodeSearchRequiresQ(t *testing.T) {
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}
func TestCodeSearchFanOutHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
case "/api/v1/repos/mathias/gitea-mcp/search":
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
} `json:"results"`
Partial bool `json:"partial"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.False(t, result.Partial)
require.Len(t, result.Results, 2)
repos := make([]string, 0, 2)
for _, r := range result.Results {
repos = append(repos, r.Repo)
}
assert.Contains(t, repos, "mathias/infra")
assert.Contains(t, repos, "mathias/gitea-mcp")
}
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/broken/search":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct{ Repo string `json:"repo"` } `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.True(t, result.Partial)
require.Len(t, result.PartialRepos, 1)
assert.Equal(t, "mathias/broken", result.PartialRepos[0])
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
}
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/alpha/search":
// low score
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/beta/search":
// high score
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 2)
// First result must be the high-score one
assert.True(t, result.Results[0].Score > result.Results[1].Score,
"expected results sorted by score desc, got %v then %v",
result.Results[0].Score, result.Results[1].Score)
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
}

View File

@@ -0,0 +1,146 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
var substitutionFiles = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func substitutions(owner, name string) map[string]string {
return map[string]string{
"__PROJECT_NAME__": name,
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
}
}
// CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct {
c *gitea.Client
a *allowlist.Allowlist
templateOwner string
templateName string
}
func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate {
return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName}
}
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "create_project_from_template",
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"},
"private":{"type":"boolean"}
},
"required":["owner","name"]
}`),
}
}
type createProjectArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
}
type createProjectResult struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args createProjectArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
// Allowlist check first.
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
// Validate name format.
if !nameRe.MatchString(args.Name) {
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
}
// Verify template exists and is marked as a template repo.
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
if err != nil {
return nil, fmt.Errorf("template lookup: %w", err)
}
if !tmpl.Template {
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
}
// Verify destination doesn't already exist.
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) {
return nil, fmt.Errorf("destination check: %w", err)
}
// Generate repo from template.
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
Owner: args.Owner,
Name: args.Name,
Description: args.Description,
Private: args.Private,
GitContent: true,
})
if err != nil {
return nil, fmt.Errorf("generate: %w", err)
}
result := createProjectResult{
FullName: newRepo.FullName,
HTMLURL: newRepo.HTMLURL,
CloneURL: newRepo.CloneURL,
DefaultBranch: newRepo.DefaultBranch,
}
// Substitute placeholders in known files (best-effort).
repls := substitutions(args.Owner, args.Name)
branch := newRepo.DefaultBranch
for _, path := range substitutionFiles {
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
// Files that don't exist in this template are silently skipped.
if errors.Is(err, gitea.ErrNotFound) {
continue
}
// Any other error halts the substitution pass with partial_failure recorded.
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
break
}
result.FilesSubstituted = append(result.FilesSubstituted, path)
}
return textOK(result)
}

View File

@@ -0,0 +1,266 @@
package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
var substitutionFileList = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
// contentWithPlaceholder is a template file body that contains the placeholder.
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
func encodedContent(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// fileContentsJSON returns a JSON FileContents object for the given path.
func fileContentsJSON(path string) string {
enc := encodedContent(contentWithPlaceholder)
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), enc)
}
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
func fileWriteResultJSON(path string) string {
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
}
// newTemplateRepoJSON returns a JSON Repo marked as template.
func newTemplateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
name, name, name, name, isTemplate)
}
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
func newGeneratedRepoJSON(name string) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
name, name, name, name)
}
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
c := gitea.NewClient(srvURL, "tok")
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
}
// TestCreateProjectHappyPath: all 6 files served and substituted.
func TestCreateProjectHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
// Template repo lookup
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
// Destination repo lookup — 404 means it doesn't exist yet
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
// Generate
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
// File contents GET — handle all 6 substitution files
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
// File contents PUT — handle all 6 substitution files
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
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","description":"A new service"}`))
require.NoError(t, err)
var out struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.Equal(t, "mathias/new-svc", out.FullName)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
assert.Equal(t, "main", out.DefaultBranch)
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure)
}
// 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)
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")
}

View File

@@ -0,0 +1,70 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type DirList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewDirList(c *gitea.Client, a *allowlist.Allowlist) *DirList {
return &DirList{c: c, a: a}
}
func (t *DirList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "dir_list",
Description: "List directory contents in a repository. Use empty path for repo root. Returns name, path, type (file/dir/symlink), sha, size per entry.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","name"]
}`),
}
}
type dirListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
}
func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args dirListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
if err != nil {
return nil, err
}
result := make([]map[string]any, len(entries))
for i, e := range entries {
result[i] = map[string]any{
"name": e.Name,
"path": e.Path,
"type": e.Type,
"sha": e.Sha,
"size": e.Size,
}
}
return textOK(result)
}

View File

@@ -0,0 +1,75 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDirListReturnsEntries(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/owner/repo/contents/src", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":512},
{"name":"util","path":"src/util","type":"dir","sha":"def","size":0}
]`))
}))
defer srv.Close()
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"src"}`))
require.NoError(t, err)
var result []map[string]any
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result, 2)
assert.Equal(t, "main.go", result[0]["name"])
assert.Equal(t, "file", result[0]["type"])
assert.Equal(t, "util", result[1]["name"])
assert.Equal(t, "dir", result[1]["type"])
}
func TestDirListRootPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/owner/repo/contents/", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":""}`))
require.NoError(t, err)
var result []map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Empty(t, result)
}
func TestDirListOnFileReturnsError(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(`{"path":"README.md","sha":"abc","size":10,"content":"","encoding":"base64"}`))
}))
defer srv.Close()
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"README.md"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestDirListAllowlistRejects(t *testing.T) {
tool := tools.NewDirList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","path":""}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,78 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type FileDelete struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewFileDelete(c *gitea.Client, a *allowlist.Allowlist) *FileDelete {
return &FileDelete{c: c, a: a}
}
func (t *FileDelete) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "file_delete",
Description: "Delete a file from a repository branch. sha is the current blob SHA (from file_read).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"branch":{"type":"string"},
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","name","path","branch","message","sha"]
}`),
}
}
type fileDeleteArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Path string `json:"path"`
Branch string `json:"branch"`
Message string `json:"message"`
Sha string `json:"sha"`
}
func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args fileDeleteArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Sha == "" {
return nil, fmt.Errorf("sha is required: %w", gitea.ErrValidation)
}
if args.Message == "" {
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
}
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
Branch: args.Branch,
Message: args.Message,
Sha: args.Sha,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"commit_sha": result.Commit.Sha,
"html_url": result.Commit.HTMLURL,
})
}

View File

@@ -0,0 +1,52 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileDeleteSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`))
}))
defer srv.Close()
tool := tools.NewFileDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"repo","path":"src/old.go",
"branch":"main","message":"remove old.go","sha":"blobsha"
}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "cmt1", result["commit_sha"])
}
func TestFileDeleteRequiresSha(t *testing.T) {
tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"repo","path":"f.go","branch":"main","message":"rm"
}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestFileDeleteAllowlistRejects(t *testing.T) {
tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"evil","name":"repo","path":"f.go","branch":"main","message":"rm","sha":"abc"
}`))
require.Error(t, err)
}

View File

@@ -57,11 +57,11 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
ref := args.Ref ref := args.Ref
if ref == "" { if ref == "" {
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name) var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ref = repo.DefaultBranch
} }
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref) fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)

View File

@@ -57,6 +57,21 @@ 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"}`))

View File

@@ -75,11 +75,11 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if !exists { if !exists {
base := args.Base base := args.Base
if base == "" { if base == "" {
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name) var err error
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
base = repo.DefaultBranch
} }
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, 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

View File

@@ -39,9 +39,9 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
_, _ = w.Write([]byte(createBranchResp)) _, _ = w.Write([]byte(createBranchResp))
}) })
// Upsert file → 201 // New file (no sha) → POST to /contents/{path}
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method) require.Equal(t, http.MethodPost, r.Method)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(upsertFileResp)) _, _ = w.Write([]byte(upsertFileResp))
}) })
@@ -64,6 +64,39 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
assert.Equal(t, "cmt1", result["commit_sha"]) assert.Equal(t, "cmt1", result["commit_sha"])
} }
func TestFileWriteBranchUsesPutWhenShaProvided(t *testing.T) {
mux := http.NewServeMux()
// Branch exists
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches/feat/existing", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(branchCheckExistsResp))
})
// Existing file (sha provided) → PUT
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(upsertFileResp))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"myrepo","path":"doc.md",
"content":"hello","branch":"feat/existing",
"sha":"oldsha","message":"update doc.md"
}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "feat/existing", result["branch"])
assert.Equal(t, "cmt1", result["commit_sha"])
}
func TestFileWriteBranchUsesDefaultBaseWhenBaseEmpty(t *testing.T) { func TestFileWriteBranchUsesDefaultBaseWhenBaseEmpty(t *testing.T) {
var createBody []byte var createBody []byte
mux := http.NewServeMux() mux := http.NewServeMux()

View File

@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueComment struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueComment(c *gitea.Client, a *allowlist.Allowlist) *IssueComment {
return &IssueComment{c: c, a: a}
}
func (t *IssueComment) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_comment",
Description: "Comment on an issue. Applies identity footer to body.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
}`),
}
}
type issueCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCommentArgs
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 must be >= 1: %w", gitea.ErrValidation)
}
if args.Body == "" {
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"id": c.ID,
"html_url": c.HTMLURL,
})
}

View File

@@ -0,0 +1,54 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const commentFixture = `{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`
func TestIssueCommentAppliesFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(commentFixture))
}))
defer srv.Close()
tool := tools.NewIssueComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":42,"body":"hello"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestIssueCommentAllowlistRejects(t *testing.T) {
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
require.Error(t, err)
}
func TestIssueCommentRequiresBody(t *testing.T) {
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,84 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueCreate(c *gitea.Client, a *allowlist.Allowlist) *IssueCreate {
return &IssueCreate{c: c, a: a}
}
func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_create",
Description: "Create an issue. Applies identity footer to body.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"labels":{"type":"array","items":{"type":"integer"}},
"assignees":{"type":"array","items":{"type":"string"}},
"milestone":{"type":"integer"}
},
"required":["owner","name","title"]
}`),
}
}
type issueCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels"`
Assignees []string `json:"assignees"`
Milestone int64 `json:"milestone"`
}
func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
return nil, fmt.Errorf("title is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
Title: args.Title,
Body: body,
Labels: args.Labels,
Assignees: args.Assignees,
Milestone: args.Milestone,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": iss.Number,
"title": iss.Title,
"html_url": iss.HTMLURL,
"state": iss.State,
})
}

View File

@@ -0,0 +1,81 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const issueFixture = `{
"number": 42,
"title": "x",
"body": "y",
"html_url": "http://example.com/issues/42",
"state": "open"
}`
func TestIssueCreateAppliesIdentityFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(issueFixture))
}))
defer srv.Close()
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestIssueCreateNoFooterWhenCallerEmpty(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.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(issueFixture))
}))
defer srv.Close()
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.NotContains(t, body, "_Created via git-mcp on behalf of")
}
func TestIssueCreateAllowlistRejects(t *testing.T) {
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","title":"T"}`))
require.Error(t, err)
}
func TestIssueCreateRequiresTitle(t *testing.T) {
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRComment struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRComment(c *gitea.Client, a *allowlist.Allowlist) *PRComment {
return &PRComment{c: c, a: a}
}
func (t *PRComment) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_comment",
Description: "Comment on a pull request (conversation, not inline review). Applies identity footer.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
}`),
}
}
type prCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prCommentArgs
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 must be >= 1: %w", gitea.ErrValidation)
}
if args.Body == "" {
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"id": c.ID,
"html_url": c.HTMLURL,
})
}

View File

@@ -0,0 +1,53 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRCommentAppliesFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// PRs share index space with issues — same endpoint
assert.Equal(t, "/api/v1/repos/o/r/issues/3/comments", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(commentFixture))
}))
defer srv.Close()
tool := tools.NewPRComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":3,"body":"looks good"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestPRCommentAllowlistRejects(t *testing.T) {
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
require.Error(t, err)
}
func TestPRCommentRequiresBody(t *testing.T) {
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,171 @@
package tools
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const (
maxFileDiffBytes = 20 * 1024
maxResponseBytes = 200 * 1024
)
type PRFilesDiff struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRFilesDiff(c *gitea.Client, a *allowlist.Allowlist) *PRFilesDiff {
return &PRFilesDiff{c: c, a: a}
}
func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_files_diff",
Description: "Get a pull request's per-file diff with size caps (20KB/file, 200KB total).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type prFilesDiffArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
type prFileDiffEntry struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
OmittedLines int `json:"omitted_lines,omitempty"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prFilesDiffArgs
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 must be >= 1: %w", gitea.ErrValidation)
}
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
// Split unified diff by per-file headers ("diff --git a/path b/path")
perFile := splitUnifiedDiff(rawDiff)
out := struct {
Files []prFileDiffEntry `json:"files"`
OmittedFiles []string `json:"omitted_files,omitempty"`
ResponseTruncated bool `json:"response_truncated"`
}{
Files: make([]prFileDiffEntry, 0, len(files)),
}
totalBytes := 0
for _, f := range files {
// look up the diff for this file (best-effort by path match)
diffBytes, ok := perFile[f.Filename]
if !ok {
diffBytes = []byte{}
}
entry := prFileDiffEntry{
Path: f.Filename,
Additions: f.Additions,
Deletions: f.Deletions,
}
// Per-file cap
if len(diffBytes) > maxFileDiffBytes {
truncated := diffBytes[:maxFileDiffBytes]
omittedLines := bytes.Count(diffBytes[maxFileDiffBytes:], []byte("\n"))
entry.Diff = string(truncated)
entry.Truncated = true
entry.OmittedLines = omittedLines
} else {
entry.Diff = string(diffBytes)
}
// Response cap — if adding this entry would exceed, push to omitted_files
entryEstimate := len(entry.Diff) + 200 // small overhead for path + counts
if totalBytes+entryEstimate > maxResponseBytes {
out.OmittedFiles = append(out.OmittedFiles, f.Filename)
out.ResponseTruncated = true
continue
}
totalBytes += entryEstimate
out.Files = append(out.Files, entry)
}
return textOK(out)
}
// splitUnifiedDiff parses a unified diff and returns a map from filename to that file's
// portion of the diff. The unified diff format starts each file with a line like
// "diff --git a/<path> b/<path>".
func splitUnifiedDiff(d []byte) map[string][]byte {
m := map[string][]byte{}
scanner := bufio.NewScanner(bytes.NewReader(d))
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // allow long diffs
var currentFile string
var current bytes.Buffer
flush := func() {
if currentFile != "" {
m[currentFile] = current.Bytes()
current.Reset()
}
}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "diff --git ") {
flush()
// Parse: "diff --git a/<path> b/<path>"
rest := strings.TrimPrefix(line, "diff --git a/")
parts := strings.SplitN(rest, " b/", 2)
if len(parts) == 2 {
currentFile = parts[0]
} else {
currentFile = ""
}
}
if currentFile != "" {
current.WriteString(line)
current.WriteByte('\n')
}
}
flush()
return m
}

View File

@@ -0,0 +1,183 @@
package tools_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildDiff builds a synthetic unified diff for a set of files.
// Each file gets `linesPerFile` added lines.
func buildDiff(files []string, linesPerFile int) string {
var sb strings.Builder
for _, f := range files {
fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", f, f)
fmt.Fprintf(&sb, "--- a/%s\n+++ b/%s\n", f, f)
fmt.Fprintf(&sb, "@@ -0,0 +1,%d @@\n", linesPerFile)
sb.WriteString(strings.Repeat("+abcdefghij\n", linesPerFile))
}
return sb.String()
}
// buildFilesJSON builds the JSON list of PullRequestFile objects.
func buildFilesJSON(files []string, additions int) string {
entries := make([]string, len(files))
for i, f := range files {
entries[i] = fmt.Sprintf(`{"filename":%q,"status":"modified","additions":%d,"deletions":0}`, f, additions)
}
return "[" + strings.Join(entries, ",") + "]"
}
// newPRFilesDiffServer creates a test server that serves both the /files and .diff endpoints.
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/o/r/pulls/1/files":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(filesJSON))
case "/api/v1/repos/o/r/pulls/1.diff":
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(rawDiff))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestPRFilesDiffSmall(t *testing.T) {
// Two files, each ~120 bytes of diff — well under per-file and total caps.
fileNames := []string{"main.go", "util.go"}
// ~10 lines each = ~120 bytes per file diff
rawDiff := buildDiff(fileNames, 10)
filesJSON := buildFilesJSON(fileNames, 10)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
} `json:"files"`
OmittedFiles []string `json:"omitted_files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.Len(t, out.Files, 2)
assert.Empty(t, out.OmittedFiles)
assert.False(t, out.ResponseTruncated)
for _, f := range out.Files {
assert.False(t, f.Truncated, "file %s should not be truncated", f.Path)
assert.NotEmpty(t, f.Diff)
assert.Equal(t, 10, f.Additions)
assert.Equal(t, 0, f.Deletions)
}
paths := []string{out.Files[0].Path, out.Files[1].Path}
assert.ElementsMatch(t, fileNames, paths)
}
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
fileNames := []string{"bigfile.go"}
linesPerFile := 2560 // ~30720 bytes > 20KB cap
rawDiff := buildDiff(fileNames, linesPerFile)
filesJSON := buildFilesJSON(fileNames, linesPerFile)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
OmittedLines int `json:"omitted_lines"`
Additions int `json:"additions"`
} `json:"files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
require.Len(t, out.Files, 1)
f := out.Files[0]
assert.Equal(t, "bigfile.go", f.Path)
assert.True(t, f.Truncated, "file should be truncated")
assert.Greater(t, f.OmittedLines, 0, "omitted_lines should be > 0")
assert.LessOrEqual(t, len(f.Diff), 20*1024+200, "diff should be capped near 20KB")
assert.False(t, out.ResponseTruncated)
}
func TestPRFilesDiffResponseCapped(t *testing.T) {
// 25 files × ~10KB diff each = ~250KB raw, well over the 200KB response cap.
// Each file: 850 lines × 12 bytes = 10200 bytes per file.
numFiles := 25
linesPerFile := 850
fileNames := make([]string, numFiles)
for i := range fileNames {
fileNames[i] = fmt.Sprintf("file%02d.go", i)
}
rawDiff := buildDiff(fileNames, linesPerFile)
filesJSON := buildFilesJSON(fileNames, linesPerFile)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
} `json:"files"`
OmittedFiles []string `json:"omitted_files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.True(t, out.ResponseTruncated, "response should be truncated")
assert.NotEmpty(t, out.OmittedFiles, "some files should be omitted")
assert.NotEmpty(t, out.Files, "some files should be included")
// Total files accounted for should equal numFiles.
totalAccountedFor := len(out.Files) + len(out.OmittedFiles)
assert.Equal(t, numFiles, totalAccountedFor)
}
func TestPRFilesDiffAllowlistRejects(t *testing.T) {
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1}`))
require.Error(t, err)
}
func TestPRFilesDiffRequiresValidNumber(t *testing.T) {
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":0}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

80
internal/tools/pr_list.go Normal file
View File

@@ -0,0 +1,80 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRList(c *gitea.Client, a *allowlist.Allowlist) *PRList {
return &PRList{c: c, a: a}
}
func (t *PRList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_list",
Description: "List pull requests. state: open (default), closed, or all. Optionally filter by head branch.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"state":{"type":"string","enum":["open","closed","all"]},
"head":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","name"]
}`),
}
}
type prListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
State string `json:"state"`
Head string `json:"head"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
state := args.State
if state == "" {
state = "open"
}
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30))
if err != nil {
return nil, err
}
result := make([]map[string]any, len(prs))
for i, pr := range prs {
result[i] = map[string]any{
"number": pr.Number,
"title": pr.Title,
"state": pr.State,
"head_branch": pr.Head.Ref,
"base_branch": pr.Base.Ref,
"draft": pr.Draft,
"html_url": pr.HTMLURL,
}
}
return textOK(result)
}

View File

@@ -0,0 +1,62 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRListReturnsOpenPRs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "open", r.URL.Query().Get("state"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{
"number":7,"title":"Add feature X","html_url":"http://example.com/pulls/7",
"state":"open","draft":false,
"head":{"ref":"feat/x"},"base":{"ref":"main"}
}]`))
}))
defer srv.Close()
tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
require.NoError(t, err)
var result []map[string]any
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result, 1)
assert.Equal(t, float64(7), result[0]["number"])
assert.Equal(t, "feat/x", result[0]["head_branch"])
assert.Equal(t, "main", result[0]["base_branch"])
}
func TestPRListDefaultsToOpen(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "open", r.URL.Query().Get("state"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
require.NoError(t, err)
var result []map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Empty(t, result)
}
func TestPRListAllowlistRejects(t *testing.T) {
tool := tools.NewPRList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,76 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRMerge struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge {
return &PRMerge{c: c, a: a}
}
func (t *PRMerge) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_merge",
Description: "Merge a pull request. style: merge (default), squash, or rebase.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"index":{"type":"integer","minimum":1},
"style":{"type":"string","enum":["merge","squash","rebase"]},
"merge_message_title":{"type":"string"},
"merge_message_field":{"type":"string"}
},
"required":["owner","name","index"]
}`),
}
}
type prMergeArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Index int `json:"index"`
Style string `json:"style"`
Title string `json:"merge_message_title"`
Body string `json:"merge_message_field"`
}
func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prMergeArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Index < 1 {
return nil, fmt.Errorf("index must be >= 1: %w", gitea.ErrValidation)
}
style := args.Style
if style == "" {
style = "merge"
}
if err := t.c.MergePullRequest(ctx, args.Owner, args.Name, args.Index, gitea.MergePRArgs{
Do: style,
Title: args.Title,
Body: args.Body,
}); err != nil {
return nil, err
}
return textOK(map[string]any{"merged": true})
}

View File

@@ -0,0 +1,70 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRMergeSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, true, result["merged"])
}
func TestPRMergeDefaultsToMergeStyle(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.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "merge", payload["Do"])
}
func TestPRMergeConflictReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict)
}
func TestPRMergeAllowlistRejects(t *testing.T) {
tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,74 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate {
return &RepoCreate{c: c, a: a}
}
func (t *RepoCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_create",
Description: "Create a repository for the authenticated user or an organisation.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string","description":"Username or org name (used for allowlist check)."},
"name":{"type":"string","description":"Repository name."},
"description":{"type":"string"},
"private":{"type":"boolean","description":"Create as private. Default false."},
"auto_init":{"type":"boolean","description":"Initialise with README."},
"default_branch":{"type":"string","description":"Default branch name. Default 'main'."},
"is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."}
},
"required":["owner","name"]
}`),
}
}
type repoCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
AutoInit bool `json:"auto_init"`
DefaultBranch string `json:"default_branch"`
IsOrg bool `json:"is_org"`
}
func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
createArgs := gitea.CreateRepoArgs{
Name: args.Name,
Description: args.Description,
Private: args.Private,
AutoInit: args.AutoInit,
DefaultBranch: args.DefaultBranch,
}
if args.IsOrg {
createArgs.Org = args.Owner
}
r, err := t.c.CreateRepo(ctx, createArgs)
if err != nil {
return nil, err
}
return textOK(r)
}

View File

@@ -0,0 +1,53 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoCreateTool_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()
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
assert.Contains(t, string(out), `"clone_url"`)
}
func TestRepoCreateTool_Org(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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()
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`)
}
func TestRepoCreateAllowlistRejects(t *testing.T) {
tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}

View File

@@ -48,10 +48,8 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
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.Limit == 0 || args.Limit > 50 { args.Limit = capLimit(args.Limit, 30)
args.Limit = 30 if args.Page < 1 {
}
if args.Page == 0 {
args.Page = 1 args.Page = 1
} }
repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit) repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit)

View File

@@ -0,0 +1,117 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoMirrorPush struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
return &RepoMirrorPush{c: c, a: a}
}
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_mirror_push",
Description: "Manage push mirrors for a repository: add, list, or delete.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"action":{"type":"string","enum":["add","list","delete"]},
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
"remote_username":{"type":"string"},
"remote_password":{"type":"string","description":"Never logged or returned."},
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
"sync_on_commit":{"type":"boolean"},
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
},
"required":["owner","name","action"]
}`),
}
}
type repoMirrorPushArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Action string `json:"action"`
RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username"`
RemotePassword string `json:"remote_password"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
MirrorName string `json:"mirror_name"`
}
// safeMirror omits remote_password so it is never returned to the caller.
type safeMirror 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"`
}
func toSafeMirror(m *gitea.PushMirror) safeMirror {
return safeMirror{
ID: m.ID,
RemoteName: m.RemoteName,
RemoteAddress: m.RemoteAddress,
Interval: m.Interval,
SyncOnCommit: m.SyncOnCommit,
}
}
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoMirrorPushArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
switch args.Action {
case "add":
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{
RemoteAddress: args.RemoteAddress,
RemoteUsername: args.RemoteUsername,
RemotePassword: args.RemotePassword,
Interval: args.Interval,
SyncOnCommit: args.SyncOnCommit,
})
if err != nil {
return nil, err
}
return textOK(toSafeMirror(m))
case "list":
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
safe := make([]safeMirror, len(mirrors))
for i := range mirrors {
safe[i] = toSafeMirror(&mirrors[i])
}
return textOK(safe)
case "delete":
if args.MirrorName == "" {
return nil, fmt.Errorf("mirror_name is required for action=delete")
}
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil {
return nil, err
}
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
default:
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
}
}

View File

@@ -0,0 +1,80 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoMirrorPushTool_Add(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()
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"mathias","name":"infra","action":"add",
"remote_address":"https://github.com/mathias/infra.git",
"remote_username":"mathias","remote_password":"secret",
"interval":"8h0m0s","sync_on_commit":true
}`))
require.NoError(t, err)
// password must never appear in output
assert.NotContains(t, string(out), "secret")
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
}
func TestRepoMirrorPushTool_List(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()
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
}
func TestRepoMirrorPushTool_Delete(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()
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`))
require.NoError(t, err)
assert.Contains(t, string(out), "deleted")
}
func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) {
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "mirror_name")
}
func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) {
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,90 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoSearch struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoSearch(c *gitea.Client, a *allowlist.Allowlist) *RepoSearch {
return &RepoSearch{c: c, a: a}
}
func (t *RepoSearch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_search",
Description: "Search repos by query string. Filters results by owner allowlist.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"q":{"type":"string"},
"owner":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["q"]
}`),
}
}
type repoSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoSearchArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if args.Owner != "" {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
}
if args.Page < 1 {
args.Page = 1
}
args.Limit = capLimit(args.Limit, 30)
repos, err := t.c.SearchRepos(ctx, args.Q, args.Owner, args.Page, args.Limit)
if err != nil {
return nil, err
}
// Post-filter when owner not specified — only allowlisted owners survive.
if args.Owner == "" {
filtered := make([]gitea.Repo, 0, len(repos))
for _, r := range repos {
parts := strings.SplitN(r.FullName, "/", 2)
if len(parts) != 2 {
continue
}
if t.a.Check(parts[0]) == nil {
filtered = append(filtered, r)
}
}
repos = filtered
}
out := map[string]any{"repos": repos}
if len(repos) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}

View File

@@ -0,0 +1,61 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoSearchWithOwner(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
assert.Equal(t, "infra", r.URL.Query().Get("q"))
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
}))
defer srv.Close()
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"mathias"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
}
func TestRepoSearchPostFiltersWithoutOwner(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No owner param expected when owner is empty
assert.Empty(t, r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"x","full_name":"mathias/x"},{"name":"y","full_name":"evil/y"}],"ok":true}`))
}))
defer srv.Close()
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"x"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"mathias/x"`)
assert.NotContains(t, string(out), `"evil/y"`)
}
func TestRepoSearchAllowlistRejectsExplicitOwner(t *testing.T) {
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"evil"}`))
require.Error(t, err)
}
func TestRepoSearchRequiresQ(t *testing.T) {
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{}`))
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}

View File

@@ -0,0 +1,104 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoStatus struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoStatus(c *gitea.Client, a *allowlist.Allowlist) *RepoStatus {
return &RepoStatus{c: c, a: a}
}
func (t *RepoStatus) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_status",
Description: "Get repo state in one call: all branches, open PRs, and protection rules for a target branch. Use this first to decide whether to use feature-branch or trunk-based development.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"}
},
"required":["owner","name"]
}`),
}
}
type repoStatusArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Branch string `json:"branch"`
}
func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoStatusArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
branch := args.Branch
if branch == "" {
var err error
branch, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, 1, 50)
if err != nil {
return nil, err
}
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, "open", "", 1, 50)
if err != nil {
return nil, err
}
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, branch)
if err != nil {
return nil, err
}
branchList := make([]map[string]any, len(branches))
for i, b := range branches {
branchList[i] = map[string]any{"name": b.Name, "sha": b.Commit.ID}
}
prList := make([]map[string]any, len(prs))
for i, pr := range prs {
prList[i] = map[string]any{
"number": pr.Number,
"title": pr.Title,
"state": pr.State,
"head_branch": pr.Head.Ref,
"base_branch": pr.Base.Ref,
"draft": pr.Draft,
"html_url": pr.HTMLURL,
}
}
return textOK(map[string]any{
"branches": branchList,
"open_prs": prList,
"protection": map[string]any{
"protected": bp.Protected,
"required_approvals": bp.RequiredApprovals,
"push_whitelist": bp.PushWhitelist,
"merge_whitelist": bp.MergeWhitelist,
},
})
}

View File

@@ -0,0 +1,131 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoStatusComposesThreeEndpoints(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"name":"main","commit":{"id":"abc","url":""}},
{"name":"feat/x","commit":{"id":"def","url":""}}
]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "open", r.URL.Query().Get("state"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{
"number":3,"title":"My PR","html_url":"http://example.com/pulls/3",
"state":"open","draft":false,
"head":{"ref":"feat/x"},"base":{"ref":"main"}
}]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
branches := result["branches"].([]any)
assert.Len(t, branches, 2)
openPRs := result["open_prs"].([]any)
assert.Len(t, openPRs, 1)
assert.Equal(t, float64(3), openPRs[0].(map[string]any)["number"])
protection := result["protection"].(map[string]any)
assert.Equal(t, true, protection["protected"])
assert.Equal(t, float64(1), protection["required_approvals"])
}
func TestRepoStatusUnprotectedBranch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","url":""}}]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
protection := result["protection"].(map[string]any)
assert.Equal(t, false, protection["protected"])
}
func TestRepoStatusAllowlistRejects(t *testing.T) {
tool := tools.NewRepoStatus(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`))
require.Error(t, err)
}
func TestRepoStatusDefaultsBranchFromRepo(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"repo","full_name":"owner/repo","default_branch":"main","description":"","private":false,"clone_url":"","html_url":""}`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","url":""}}]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
})
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"required_approvals":0,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
// no "branch" field — triggers DefaultBranch fallback
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.NotNil(t, result["branches"])
assert.NotNil(t, result["open_prs"])
assert.NotNil(t, result["protection"])
}

View File

@@ -0,0 +1,55 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoTopicsUpdate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate {
return &RepoTopicsUpdate{c: c, a: a}
}
func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_topics_update",
Description: "Replace the topic list for a repository.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."}
},
"required":["owner","name","topics"]
}`),
}
}
type repoTopicsUpdateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Topics []string `json:"topics"`
}
func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoTopicsUpdateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil {
return nil, err
}
return textOK(map[string]any{"status": "updated", "topics": args.Topics})
}

View File

@@ -0,0 +1,35 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoTopicsUpdateTool(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()
tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`))
require.NoError(t, err)
assert.Contains(t, string(out), "updated")
}
func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) {
tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoTree struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree {
return &RepoTree{c: c, a: a}
}
func (t *RepoTree) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_tree",
Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"ref":{"type":"string","description":"Branch, tag, or commit SHA."}
},
"required":["owner","name","ref"]
}`),
}
}
type repoTreeArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Ref string `json:"ref"`
}
func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoTreeArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true)
if err != nil {
return nil, err
}
return textOK(tree)
}

View File

@@ -0,0 +1,50 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoTreeTool(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","size":0}],"truncated":false}`))
}))
defer srv.Close()
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"sha":"abc"`)
assert.Contains(t, string(out), `"path":"README.md"`)
}
func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`))
}))
defer srv.Close()
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
require.NoError(t, err)
}
func TestRepoTreeAllowlistRejects(t *testing.T) {
tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,76 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoUpdate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
return &RepoUpdate{c: c, a: a}
}
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_update",
Description: "Update repository metadata (description, visibility, default branch, website).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"description":{"type":"string"},
"private":{"type":"boolean"},
"website":{"type":"string"},
"default_branch":{"type":"string"},
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
},
"required":["owner","name"]
}`),
}
}
type repoUpdateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description *string `json:"description"`
Private *bool `json:"private"`
Website *string `json:"website"`
DefaultBranch *string `json:"default_branch"`
Confirm string `json:"confirm"`
}
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoUpdateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
// Making a repo public is a significant action — require explicit confirmation.
if args.Private != nil && !*args.Private {
if args.Confirm != args.Name {
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
}
}
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
Description: args.Description,
Private: args.Private,
Website: args.Website,
DefaultBranch: args.DefaultBranch,
})
if err != nil {
return nil, err
}
return textOK(r)
}

View File

@@ -0,0 +1,56 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoUpdateTool(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":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
}))
defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"description":"updated"`)
}
func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) {
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "confirm")
}
func TestRepoUpdateTool_MakePublicWithConfirm(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(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`))
}))
defer srv.Close()
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
}
func TestRepoUpdateAllowlistRejects(t *testing.T) {
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,76 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type TagCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewTagCreate(c *gitea.Client, a *allowlist.Allowlist) *TagCreate {
return &TagCreate{c: c, a: a}
}
func (t *TagCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "tag_create",
Description: "Create a tag pointing at a branch or commit SHA. Add a message to create an annotated tag.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"tag":{"type":"string"},
"target":{"type":"string"},
"message":{"type":"string"}
},
"required":["owner","name","tag","target"]
}`),
}
}
type tagCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Tag string `json:"tag"`
Target string `json:"target"`
Message string `json:"message"`
}
func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args tagCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Tag == "" {
return nil, fmt.Errorf("tag is required: %w", gitea.ErrValidation)
}
if args.Target == "" {
return nil, fmt.Errorf("target is required: %w", gitea.ErrValidation)
}
tag, err := t.c.CreateTag(ctx, args.Owner, args.Name, gitea.CreateTagArgs{
TagName: args.Tag,
Target: args.Target,
Message: args.Message,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"tag": tag.Name,
"commit_sha": tag.Commit.Sha,
})
}

View File

@@ -0,0 +1,52 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagCreateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/owner/repo/tags", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"name":"v2.0.0","id":"tagsha",
"commit":{"sha":"cmt1","url":""}
}`))
}))
defer srv.Close()
tool := tools.NewTagCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"repo","tag":"v2.0.0","target":"main"
}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "v2.0.0", result["tag"])
assert.Equal(t, "cmt1", result["commit_sha"])
}
func TestTagCreateRequiresTag(t *testing.T) {
tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","target":"main"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestTagCreateAllowlistRejects(t *testing.T) {
tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","tag":"v1.0.0","target":"main"}`))
require.Error(t, err)
}

View File

@@ -1,7 +1,6 @@
package tools package tools
import ( import (
"context"
"encoding/json" "encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
@@ -21,4 +20,13 @@ func parseArgs(raw json.RawMessage, dst any) error {
return json.Unmarshal(raw, dst) return json.Unmarshal(raw, dst)
} }
func _ctx(ctx context.Context) context.Context { return ctx } // stub for future hooks // capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
func capLimit(in, def int) int {
if in <= 0 {
return def
}
if in > 50 {
return 50
}
return in
}

View File

@@ -61,11 +61,11 @@ func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (jso
ref := args.Ref ref := args.Ref
if ref == "" { if ref == "" {
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name) var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ref = repo.DefaultBranch
} }
result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{ result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{

201
scripts/context-sync.sh Executable file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Generates harness-specific context files from .context/PROJECT.md
# Project-level script — run from a project directory.
#
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
# For everything else: concatenates root AGENT.md + project PROJECT.md
#
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
# Task: task context:sync
#
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
set -euo pipefail
# Parse --force flag and collect adapter names separately
FORCE=false
ADAPTERS=()
for _arg in "$@"; do
case "$_arg" in
--force) FORCE=true ;;
*) ADAPTERS+=("$_arg") ;;
esac
done
PROJECT_FILE=".context/PROJECT.md"
# Walk up to find root .context/AGENT.md
find_root_context() {
local dir
dir="$(pwd)"
while [ "$dir" != "/" ]; do
dir="$(dirname "$dir")"
if [ -f "$dir/.context/AGENT.md" ]; then
echo "$dir/.context/AGENT.md"
return
fi
done
echo ""
}
ROOT_CONTEXT="${ROOT_CONTEXT:-$(find_root_context)}"
if [ ! -f "$PROJECT_FILE" ]; then
echo "Error: $PROJECT_FILE not found. Are you in a project root?"
exit 1
fi
# Pre-flight: reject unfilled {{...}} placeholders unless --force
if [ "$FORCE" = false ]; then
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
if [ -n "$_placeholders" ]; then
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
while IFS= read -r _match; do
_lineno="${_match%%:*}"
_content="${_match#*:}"
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
done <<< "$_placeholders"
echo "" >&2
echo "Fill these placeholders, then re-run: task context:sync" >&2
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
exit 1
fi
fi
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
echo " Root context: $ROOT_CONTEXT"
else
echo " No root AGENT.md found (project context only)"
fi
# Emit root context + separator
root_block() {
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
cat "$ROOT_CONTEXT"
echo ""
echo "---"
echo ""
fi
}
# ── Claude Code ──────────────────────────────────────────────
# Claude Code walks up the tree — it finds ~/dev/CLAUDE.md automatically.
# Project-level CLAUDE.md only needs project-specific context.
generate_claude() {
cat "$PROJECT_FILE" > CLAUDE.md
echo " → CLAUDE.md (project-only; Claude Code inherits root)"
}
# ── AGENTS.md (Crush, Pi, Antigravity) ──────────────────────
# These tools read AGENTS.md from cwd but don't walk up.
# Concatenate root + project.
generate_agents() {
{ root_block; cat "$PROJECT_FILE"; } > AGENTS.md
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
}
# ── Cursor ───────────────────────────────────────────────────
generate_cursor() {
{
echo "# Cursor rules — auto-generated"
echo "# Do not edit. Run: task context:sync"
echo ""
root_block
cat "$PROJECT_FILE"
} > .cursorrules
echo " → .cursorrules (root + project)"
}
# ── Aider ────────────────────────────────────────────────────
generate_aider() {
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
if [ ! -f .aider.conf.yml ]; then
cat > .aider.conf.yml << 'YAML'
read: .aider.conventions.md
auto-commits: false
YAML
fi
echo " → .aider.conventions.md (root + project)"
}
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
generate_system_prompt() {
{
echo "You are a coding assistant working on a specific project."
echo "Follow all conventions from both the root agent context and project context."
echo ""
echo "---"
echo ""
root_block
cat "$PROJECT_FILE"
echo ""
echo "---"
} > .context/system-prompt.txt
echo " → .context/system-prompt.txt (root + project)"
}
# ── MCP config ───────────────────────────────────────────────
generate_mcp() {
# Ensure baseline file exists with project-specific knowledge server
if [ ! -f .context/mcp.json ]; then
cat > .context/mcp.json << 'JSON'
{
"mcpServers": {
"knowledge": {
"url": "http://localhost:3100/mcp",
"description": "Project knowledge base — vector + graph retrieval"
}
}
}
JSON
fi
# Merge root mcp-servers.json if found alongside root AGENT.md
local root_mcp=""
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
local candidate
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
[ -f "$candidate" ] && root_mcp="$candidate"
fi
if [ -z "$root_mcp" ]; then
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
return
fi
# Root servers take precedence over project entries on key conflict
local root_servers count updated
root_servers=$(jq '.servers' "$root_mcp")
count=$(printf '%s' "$root_servers" | jq 'keys | length')
updated=$(jq --argjson root "$root_servers" \
'.mcpServers = (.mcpServers + $root)' \
.context/mcp.json)
printf '%s\n' "$updated" > .context/mcp.json
echo " → .context/mcp.json (merged $count root servers)"
}
echo "Syncing project context from $PROJECT_FILE..."
if [ ${#ADAPTERS[@]} -eq 0 ]; then
generate_claude
generate_agents
generate_cursor
generate_aider
generate_system_prompt
generate_mcp
else
for adapter in "${ADAPTERS[@]}"; do
case "$adapter" in
claude) generate_claude ;;
agents) generate_agents ;;
cursor) generate_cursor ;;
aider) generate_aider ;;
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
mcp) generate_mcp ;;
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
esac
done
fi
echo "Done."