Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43714047be | ||
|
|
5601927dc8 | ||
|
|
a16c5b0537 | ||
|
|
1eceb5f7fe | ||
|
|
09a7fad6ba | ||
|
|
02af7ee71c | ||
|
|
240c3ec081 | ||
|
|
51b823ae79 | ||
|
|
ac337955e7 | ||
|
|
b288462a1c | ||
|
|
82823aad1e | ||
|
|
64176fe6d7 | ||
|
|
6d344c74a8 | ||
|
|
a515f53731 | ||
|
|
f0527c94bc | ||
|
|
c40a9d9003 | ||
|
|
8ebad95adf | ||
|
|
169040c073 | ||
|
|
834a994af9 | ||
|
|
0a12e905c9 | ||
|
|
72ba89be63 | ||
|
|
9b7f53bdda | ||
|
|
4c1f36f9eb | ||
|
|
99586984bf | ||
|
|
e0bede0547 | ||
|
|
9f12db3d94 | ||
|
|
93c32edbb5 | ||
|
|
4ebea7d023 | ||
|
|
711dc46e5e | ||
|
|
039598855c | ||
|
|
d45ba712ce | ||
|
|
4d658004ae | ||
|
|
3329ff3088 | ||
|
|
2ebaee8d03 | ||
|
|
e30951ad72 | ||
|
|
e3cdd23260 | ||
|
|
184d5a95dd | ||
|
|
e2594f45f2 | ||
|
|
f99a8b38cb | ||
|
|
8a751741a4 | ||
|
|
668e8fa28d | ||
|
|
3c1ca7d3db | ||
|
|
8bea0d2f27 | ||
|
|
24c353383f | ||
|
|
be85baf410 | ||
|
|
658f4ba84f | ||
|
|
60212fc5d2 | ||
|
|
dc907fb7e0 | ||
|
|
c4bd3396c4 | ||
|
|
11f86f5d99 | ||
|
|
f7076c9ac8 | ||
|
|
e31fd3f023 | ||
|
|
3cccbfb8cb | ||
|
|
3648373333 | ||
|
|
eeefc626ed | ||
|
|
5545d6ab4b | ||
|
|
9013c8ff9c | ||
|
|
f26f922c96 | ||
|
|
a414222610 | ||
|
|
3b490271ef | ||
|
|
d4dddbdb6c | ||
|
|
a69d3a8b76 | ||
|
|
5f3ad99122 | ||
|
|
2c94de7b59 | ||
|
|
e2da495581 | ||
|
|
7178ae32be | ||
|
|
cb4f0caf0b | ||
|
|
174669b9f6 | ||
|
|
7a53935a9e | ||
|
|
3795800461 | ||
|
|
9987522f1a | ||
|
|
0e53738d9f | ||
|
|
91be18c100 | ||
|
|
efbbd37882 | ||
|
|
9d08352324 | ||
|
|
70173875d8 | ||
|
|
3784bcc31b | ||
|
|
f63605bdd0 | ||
|
|
c4d3735272 | ||
|
|
d8db786e27 | ||
|
|
923689afa5 | ||
|
|
9a5d0005c5 | ||
|
|
c0576359d7 | ||
|
|
0c5903a196 | ||
|
|
839fc93dcd | ||
|
|
5dac4856bd | ||
|
|
0eb9ebcafd | ||
|
|
284d5e19f6 | ||
|
|
388131c8cd | ||
|
|
ddfcc32afd | ||
|
|
9e4251c1a7 | ||
|
|
06882d185e | ||
|
|
073d88b29a | ||
|
|
44c42fa636 | ||
|
|
e7bd954e90 | ||
|
|
0cd465fb68 | ||
|
|
4f0f65e26a | ||
|
|
9cbb564cd9 | ||
|
|
47e631da23 | ||
|
|
d35ff9781c | ||
|
|
052827320a | ||
|
|
c85197ea5e |
@@ -0,0 +1,2 @@
|
||||
read: .aider.conventions.md
|
||||
auto-commits: false
|
||||
@@ -0,0 +1,386 @@
|
||||
# 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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
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. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
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 — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
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. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
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 — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
|
||||
---
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
# 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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
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. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
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 — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
+10
-4
@@ -1,11 +1,13 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
"on":
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# Docs-only pushes don't change the image — skip the build/deploy roll.
|
||||
# (Only applies to branch pushes; tag pushes always trigger.)
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
|
||||
env:
|
||||
IMAGE: gitea-mcp
|
||||
@@ -43,7 +45,6 @@ jobs:
|
||||
name: Build & Import
|
||||
needs: check
|
||||
runs-on: self-hosted
|
||||
if: github.event_name != 'pull_request'
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||
steps:
|
||||
@@ -63,7 +64,11 @@ jobs:
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
# Stamp the real build version: the git tag on a v* build, else the short sha.
|
||||
VERSION="${{ steps.meta.outputs.version-tag }}"
|
||||
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
|
||||
buildah build \
|
||||
--build-arg VERSION="${VERSION}" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
||||
-t ${REF} \
|
||||
@@ -95,6 +100,7 @@ jobs:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "→ Running task check before push..."
|
||||
task check
|
||||
echo "✓ pre-push check passed"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,386 @@
|
||||
# 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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
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. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||
in a command's argv (it lands in the tool call and the transcript).
|
||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||
the secret; do not forward a raw key over ssh argv/stdout.
|
||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||
don't bury it.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
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 — actively use it
|
||||
|
||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||
reference material — query it actively, not just when explicitly told.**
|
||||
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
| Task type | Load |
|
||||
|-----------|------|
|
||||
| Any feature or bug fix | `tdd` |
|
||||
| Refactor or design | `clean-code` or `solid` |
|
||||
| Debug | `problem-analysis` |
|
||||
| Review code or PRs | `code-review` |
|
||||
| Frame a problem before coding | `problem-analysis` |
|
||||
|
||||
---
|
||||
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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://git.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:`
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
- 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. Commit directly to main. Run `task check` before every push. Never create
|
||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
|
||||
## Current state — v0.2.5 (2026-05-17)
|
||||
|
||||
All v0.2 work is complete and deployed. No active sprint.
|
||||
|
||||
### What shipped
|
||||
|
||||
| Tag | PR | Tools / fixes |
|
||||
|-----|----|---------------|
|
||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||
|
||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||
|
||||
### Next up
|
||||
|
||||
1. **`hyperguild new-project` v1** — primary next target.
|
||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||
|
||||
2. **Issue #19** — end-to-end mirror flow verification.
|
||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||
+15
-1
@@ -1,9 +1,23 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
|
||||
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||
# the module proxy + sumdb.
|
||||
RUN apk add --no-cache git && \
|
||||
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
|
||||
ENV GOPRIVATE=git.d-ma.be
|
||||
ENV GOPROXY=direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
|
||||
# Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
|
||||
# "dev" for a plain `docker build` (#47).
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/gitea-mcp /gitea-mcp
|
||||
|
||||
@@ -2,3 +2,14 @@
|
||||
|
||||
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
||||
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
task setup:hooks # installs .githooks/pre-push — runs task check before every push
|
||||
task check # context sync + lint + test + vet
|
||||
task build # produces bin/gitea-mcp
|
||||
```
|
||||
|
||||
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
|
||||
hook enforces the quality gate locally; CI re-runs `task check` on every push.
|
||||
|
||||
+44
-4
@@ -14,9 +14,49 @@ tasks:
|
||||
lint:
|
||||
desc: Run golangci-lint
|
||||
cmds: [golangci-lint run ./...]
|
||||
check:
|
||||
desc: Lint, vet, and test (used by CI)
|
||||
vet:
|
||||
cmds:
|
||||
- golangci-lint run ./...
|
||||
- go vet ./...
|
||||
- go test ./... -race -count=1
|
||||
- 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
|
||||
|
||||
setup:hooks:
|
||||
desc: Install git hooks (.githooks/pre-push)
|
||||
cmds:
|
||||
- git config core.hooksPath .githooks
|
||||
- chmod +x .githooks/pre-push
|
||||
- echo "✓ git hooks installed (pre-push runs task check)"
|
||||
|
||||
context:sync:claude:
|
||||
cmds: [bash scripts/context-sync.sh claude]
|
||||
context:sync:agents:
|
||||
cmds: [bash scripts/context-sync.sh agents]
|
||||
context:sync:cursor:
|
||||
cmds: [bash scripts/context-sync.sh cursor]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type healthStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
JWT jwtStatus `json:"jwt"`
|
||||
}
|
||||
|
||||
// jwtStatus surfaces the runtime state of the Dex JWT validator so ops
|
||||
// can distinguish "Dex unreachable at startup" from "JWT auth not
|
||||
// configured" — both previously degraded silently to static-token-only
|
||||
// (refs hyperguild/gitea-mcp#6).
|
||||
type jwtStatus struct {
|
||||
// Status is one of: "disabled" (DEX_ISSUER_URL not set),
|
||||
// "enabled" (validator initialized), "degraded" (configured but
|
||||
// init failed; only static-token auth currently accepted).
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
||||
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
||||
if initErr != nil {
|
||||
status.JWT.LastError = initErr.Error()
|
||||
}
|
||||
body, _ := json.Marshal(status)
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func jwtStatusFor(dexConfigured, validatorReady bool) string {
|
||||
switch {
|
||||
case !dexConfigured:
|
||||
return "disabled"
|
||||
case validatorReady:
|
||||
return "enabled"
|
||||
default:
|
||||
return "degraded"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHealthzHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dexConfigured bool
|
||||
validatorReady bool
|
||||
initErr error
|
||||
wantStatus string
|
||||
wantLastError string
|
||||
}{
|
||||
{
|
||||
name: "disabled when DEX_ISSUER_URL unset",
|
||||
wantStatus: "disabled",
|
||||
wantLastError: "",
|
||||
},
|
||||
{
|
||||
name: "enabled when validator initialized",
|
||||
dexConfigured: true,
|
||||
validatorReady: true,
|
||||
wantStatus: "enabled",
|
||||
wantLastError: "",
|
||||
},
|
||||
{
|
||||
name: "degraded when Dex configured but init failed",
|
||||
dexConfigured: true,
|
||||
initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"),
|
||||
wantStatus: "degraded",
|
||||
wantLastError: "fetch oidc discovery: dial tcp: connection refused",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
|
||||
var got healthStatus
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.True(t, got.OK)
|
||||
assert.Equal(t, tc.wantStatus, got.JWT.Status)
|
||||
assert.Equal(t, tc.wantLastError, got.JWT.LastError)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
saCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
|
||||
)
|
||||
|
||||
// k8sBearerRT adds this pod's ServiceAccount bearer to every request. k3s serves
|
||||
// its OIDC discovery/JWKS over the cluster CA and requires an AUTHENTICATED
|
||||
// request (anonymous is 401), so the JWKS fetch must carry a token — ADR-0011.
|
||||
type k8sBearerRT struct {
|
||||
token string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (b k8sBearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r.Header.Set("Authorization", "Bearer "+b.token)
|
||||
return b.base.RoundTrip(r)
|
||||
}
|
||||
|
||||
// k8sOIDCClient builds an HTTP client that can reach the in-cluster k8s OIDC
|
||||
// discovery + JWKS: it trusts the cluster CA and carries this pod's SA bearer.
|
||||
// Returns an error (not running in a pod, files unreadable) so the caller can
|
||||
// skip the k8s issuer without disabling other auth.
|
||||
func k8sOIDCClient() (*http.Client, error) {
|
||||
ca, err := os.ReadFile(saCAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read cluster CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(ca) {
|
||||
return nil, fmt.Errorf("parse cluster CA: no certs in %s", saCAFile)
|
||||
}
|
||||
tok, err := os.ReadFile(saTokenFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SA token: %w", err)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: k8sBearerRT{
|
||||
token: strings.TrimSpace(string(tok)),
|
||||
base: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
+78
-29
@@ -1,21 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
)
|
||||
|
||||
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
|
||||
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
|
||||
// "0.1.0" — it now tells the truth instead of a stale literal).
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
|
||||
// the same structured handler as the rest of the server.
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -23,40 +35,77 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
|
||||
ctx := context.Background()
|
||||
|
||||
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
|
||||
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
|
||||
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
|
||||
// can't be built (not in a pod) or its discovery is unreachable at startup,
|
||||
// it is dropped so existing Authentik/static auth is never taken down.
|
||||
var issuers []chassisauth.IssuerConfig
|
||||
if cfg.DexIssuerURL != "" {
|
||||
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
|
||||
}
|
||||
if cfg.K8sIssuerURL != "" {
|
||||
if client, cerr := k8sOIDCClient(); cerr != nil {
|
||||
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
|
||||
} else {
|
||||
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
|
||||
}
|
||||
}
|
||||
|
||||
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
|
||||
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
|
||||
// startup. Don't let it take down Authentik JWT auth — retry without it.
|
||||
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
|
||||
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
|
||||
for _, ic := range issuers {
|
||||
if ic.IssuerURL != cfg.K8sIssuerURL {
|
||||
pub = append(pub, ic)
|
||||
}
|
||||
}
|
||||
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
|
||||
}
|
||||
if jwtInitErr != nil {
|
||||
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||
|
||||
reg := registry.New()
|
||||
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
|
||||
|
||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||
Registry: reg,
|
||||
Sessions: mcp.NewSessionStore(),
|
||||
})
|
||||
|
||||
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
||||
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
||||
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
||||
var resourceMetadataURL string
|
||||
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(auth.CallerMiddleware(mcpSrv)))
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||
auth.CallerMiddleware(logger, mcpSrv),
|
||||
),
|
||||
),
|
||||
))
|
||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||
if cfg.DexIssuerURL != "" {
|
||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||
}
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
||||
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
logger.Error("server stopped", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# How gitea-mcp works
|
||||
|
||||
`gitea-mcp` is a custom **MCP (Model Context Protocol) front door for Gitea** — a
|
||||
small Go HTTP service that exposes ~60 Gitea operations as MCP tools over
|
||||
Streamable HTTP, so a claude.ai connector (or any MCP client) can drive the
|
||||
homelab's Gitea (`https://git.d-ma.be`) with proper auth, an owner allowlist,
|
||||
caller attribution, and defensive input handling.
|
||||
|
||||
It is **not** a generic Gitea proxy. It is an opinionated, allowlisted, single-
|
||||
tenant front door built on the shared **`mcp-chassis`** auth library.
|
||||
|
||||
---
|
||||
|
||||
## 1. Request lifecycle
|
||||
|
||||
Everything is wired in `cmd/gitea-mcp/main.go` on a plain `http.ServeMux`:
|
||||
|
||||
```
|
||||
POST /mcp
|
||||
→ OriginAllowlist (internal/mcp/origin.go) browser Origin gate
|
||||
→ BearerMiddleware (mcp-chassis auth) authN: static token OR JWT
|
||||
→ CallerMiddleware (internal/auth/caller.go) extract caller identity
|
||||
→ MCP server (internal/mcp/server.go) JSON-RPC dispatch → tools
|
||||
|
||||
GET /healthz unauthenticated health JSON
|
||||
GET /.well-known/oauth-protected-resource RFC 9728 metadata (when Dex set)
|
||||
```
|
||||
|
||||
The middleware order is deliberate: Origin first (cheap reject), then authN,
|
||||
then identity extraction, then the MCP handler. A tool call is a JSON-RPC
|
||||
`tools/call` that the registry dispatches to a `Tool` handler.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication (`mcp-chassis` `BearerMiddleware`)
|
||||
|
||||
`Authorization: Bearer <token>` is checked with this precedence (`auth/bearer.go`
|
||||
in `mcp-chassis`):
|
||||
|
||||
1. **Static bearer** — constant-time compare against `GITEA_MCP_STATIC_TOKEN`.
|
||||
Wins immediately, never emits a `WWW-Authenticate` challenge. This is the
|
||||
service-to-service path (repo `.mcp.json` files carrying `GITEA_MCP_TOKEN`).
|
||||
2. **JWT validation** — against a **list of trusted OIDC issuers**
|
||||
(`NewMultiJWTValidator`, ADR-0011):
|
||||
- **Authentik** (`DEX_ISSUER_URL` + `MCP_AUDIENCE=claude-ai`) — the claude.ai
|
||||
web connector after its OAuth handshake.
|
||||
- **k3s cluster OIDC** (`K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp`) — lets
|
||||
in-cluster pods authenticate with audience-bound projected **ServiceAccount
|
||||
tokens** instead of a static bearer. The k8s issuer is additive and
|
||||
best-effort: if the in-cluster OIDC can't be reached at startup it is
|
||||
dropped so Authentik auth is never taken down (see `cmd/gitea-mcp/main.go`
|
||||
and `cmd/gitea-mcp/k8soidc.go`).
|
||||
3. Otherwise **401**. If `MCP_RESOURCE_URL` + `DEX_ISSUER_URL` are set, the 401
|
||||
carries a `WWW-Authenticate: … resource_metadata=…` header (RFC 9728) so
|
||||
claude.ai's OAuth discovery can find `/.well-known/oauth-protected-resource`.
|
||||
|
||||
A JWKS/issuer outage yields **503** (`ErrUnavailable`), distinct from a
|
||||
present-but-invalid token's **401**, so a transient IdP blip is retried rather
|
||||
than treated as a hard auth failure.
|
||||
|
||||
### The k8s SA-token wrinkle (ADR-0011)
|
||||
|
||||
k3s serves its OIDC discovery/JWKS over the **cluster CA** and requires an
|
||||
**authenticated** request (anonymous → 401). So `k8sOIDCClient()` builds an HTTP
|
||||
client that trusts `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` and
|
||||
carries the pod's own SA bearer, passed to the chassis via
|
||||
`IssuerConfig.HTTPClient`. Proven live: a real `aud=gitea-mcp` SA token → the
|
||||
running server → HTTP 200.
|
||||
|
||||
### Origin allowlist
|
||||
|
||||
`OriginAllowlist` (`internal/mcp/origin.go`) rejects any request whose `Origin`
|
||||
header is not in `GITEA_MCP_ORIGIN_ALLOWLIST` (`https://claude.ai`,
|
||||
`https://api.anthropic.com`). An **empty** Origin (server-side callers) is
|
||||
allowed — Origin is a browser-only header.
|
||||
|
||||
---
|
||||
|
||||
## 3. Authorization, identity & attribution
|
||||
|
||||
- **Owner allowlist** (`internal/allowlist`) — tools only operate on owners in
|
||||
`GITEA_MCP_ALLOWED_OWNERS` (default `mathias`). A call for any other owner is
|
||||
rejected before it reaches Gitea.
|
||||
- **Caller identity** (`internal/auth/caller.go`) — the authenticated username is
|
||||
read from reverse-proxy identity headers, `X-Auth-Request-User` (the verified
|
||||
OIDC identity, authoritative) preferred over `X-Forwarded-User`. Conflicts are
|
||||
logged, not silently resolved.
|
||||
- **Identity footer** (`internal/identity/footer.go`) — mutating tools that write
|
||||
a body (issue/PR comments, creates) append
|
||||
`_Created via git-mcp on behalf of @<caller>_`, so actions taken through the
|
||||
front door are attributable even though all upstream calls use one service PAT.
|
||||
|
||||
---
|
||||
|
||||
## 4. Upstream Gitea access
|
||||
|
||||
`internal/gitea/Client` is a thin REST client over `GITEA_BASE_URL`
|
||||
(`https://git.d-ma.be`). Every upstream call carries a **single service PAT** —
|
||||
`GITEA_MCP_DEFAULT_TOKEN` — as `Authorization: token <PAT>` (`client.go`). So
|
||||
gitea-mcp is a *front door*, not a credential pass-through: the caller's identity
|
||||
is captured for attribution, but Gitea sees one service account. A 60s branch
|
||||
cache and a 30s HTTP timeout round it out.
|
||||
|
||||
Defensive guard: paths with an empty owner/repo segment (`//`) are rejected
|
||||
locally (`hasEmptySegment`) so callers get a typed validation error instead of
|
||||
Gitea's opaque `/api/swagger` 404.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tools
|
||||
|
||||
Tools are registered in `internal/tools/registry.go` into a `registry.Registry`;
|
||||
each implements the `Tool` interface (name, JSON schema, handler). ~60 tools,
|
||||
by area:
|
||||
|
||||
| Area | Tools |
|
||||
|------|-------|
|
||||
| Repos | list, get, search, status, create, delete, update, tree, topics_update, mirror_push |
|
||||
| Files | file_read, file_write_branch, file_delete, dir_list |
|
||||
| Branches | branch_list, branch_delete, branch_protection_get |
|
||||
| Issues | list, get, create, edit, close, reopen, comment, label, list_comments |
|
||||
| PRs | list, get, create, comment, merge, files_diff |
|
||||
| Labels / Releases / Tags | label_list, release_create, tag_create |
|
||||
| Workflows (Actions) | run_list, run_status, run_trigger |
|
||||
| Search / Templates | code_search, create_project_from_template |
|
||||
| Composite | tbd_ship (trunk-based ship helper) |
|
||||
|
||||
Shared tool-layer hygiene (`internal/tools/tool.go`):
|
||||
- **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so
|
||||
a tool works whichever spelling a caller sends (an explicit canonical wins).
|
||||
- **Required-identifier validation** — an empty `repo`/`name` is rejected at the
|
||||
tool layer (avoids the bare-404 leak); `owner` is covered by the allowlist.
|
||||
- **Page-size cap** — limits are clamped to 50.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration (env)
|
||||
|
||||
| Var | Purpose | Example |
|
||||
|-----|---------|---------|
|
||||
| `GITEA_MCP_PORT` | listen port | `8080` |
|
||||
| `GITEA_BASE_URL` | upstream Gitea | `https://git.d-ma.be` |
|
||||
| `GITEA_MCP_DEFAULT_TOKEN` | **upstream** service PAT (all Gitea calls) | *(secret)* |
|
||||
| `GITEA_MCP_STATIC_TOKEN` | static bearer for service-to-service auth | *(secret)* |
|
||||
| `GITEA_MCP_ALLOWED_OWNERS` | owner allowlist | `mathias` |
|
||||
| `GITEA_MCP_ORIGIN_ALLOWLIST` | permitted browser Origins | `https://claude.ai,…` |
|
||||
| `DEX_ISSUER_URL` / `MCP_AUDIENCE` | Authentik issuer + audience | `…/o/claude-ai/`, `claude-ai` |
|
||||
| `K8S_ISSUER_URL` / `K8S_MCP_AUDIENCE` | k8s OIDC issuer + audience (SA tokens) | `https://kubernetes.default.svc.cluster.local`, `gitea-mcp` |
|
||||
| `MCP_RESOURCE_URL` | this server's public URL for `.well-known` metadata | `https://git-mcp.d-ma.be` |
|
||||
|
||||
Secrets come from k8s Secrets (`gitea-mcp-secrets`, `gitea-mcp-static-token`);
|
||||
non-secret values are inlined in the Deployment.
|
||||
|
||||
---
|
||||
|
||||
## 7. Health & observability
|
||||
|
||||
- `GET /healthz` → `{"ok":true,"jwt":{"status":"disabled|enabled|degraded","last_error":"…"}}`.
|
||||
`degraded` = a JWT issuer was configured but init failed (static-token auth
|
||||
still works) — distinguishes "IdP unreachable" from "JWT not configured".
|
||||
- Every auth rejection is audit-logged (`mcp-chassis` `deny`) with the reason,
|
||||
client IP, token *type*, and a truncated SHA-256 **fingerprint** — never the
|
||||
raw token, so nothing secret lands in logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment & CI/CD
|
||||
|
||||
- **Runs in** the `gitea-mcp` k3s namespace (`git.d-ma.be/mathias/infra`
|
||||
`k3s/apps/gitea-mcp/`): a Deployment on `localhost:5000/gitea-mcp:<sha>`, an
|
||||
ExternalSecret for tokens, and a **default-deny NetworkPolicy** + an
|
||||
`allow-ingress-nginx` rule (this namespace is the P6.1 netpol pilot — the only
|
||||
ingress allowed is from `ingress-nginx`).
|
||||
- **Public endpoint** `https://git-mcp.d-ma.be` (NPM → ingress-nginx → svc:8080).
|
||||
- **Pipeline** (`.gitea/workflows/cd.yml`, self-hosted koala runner): quality gate
|
||||
(test/vet) → `buildah` image → `localhost:5000` → smoke test → the deploy job
|
||||
clones infra over SSH, `sed`s the image tag in `k3s/apps/gitea-mcp/deployment.yaml`,
|
||||
commits, and triggers a **Flux** reconcile. Push to `main` ⇒ new image ⇒ rolled.
|
||||
|
||||
---
|
||||
|
||||
## 9. Operational notes
|
||||
|
||||
- **Rollback** = revert the image tag (or the env change) in the infra Deployment;
|
||||
Flux rolls back. Auth changes are additive, so enabling/disabling the k8s issuer
|
||||
never affects the Authentik/static paths.
|
||||
- **NetworkPolicy kill switch** — delete the `gitea-mcp` NetworkPolicies to
|
||||
restore open ingress if the pilot ever locks something out.
|
||||
- **claude.ai connector flakiness** — the claude.ai→gitea-mcp MCP path
|
||||
intermittently hits a Cloudflare "you have been blocked" page (the error names
|
||||
`anthropic.com`). On the Claude Code CLI, prefer the direct Gitea REST API for
|
||||
reliability; the MCP connector remains the claude.ai-web path. (brain:
|
||||
`prefer-rest-api-skills-over-mcp-on-cli-cloudflare-waf`.)
|
||||
|
||||
## References
|
||||
- Auth library: `git.d-ma.be/mathias/mcp-chassis` (`auth` package) — shared
|
||||
`BearerMiddleware` + multi-issuer `JWTValidator` + RFC 9728 handler.
|
||||
- Multi-issuer / SA-token design: infra `docs/decisions/ADR-0011-service-to-service-auth.md`.
|
||||
- Source map: `cmd/gitea-mcp/{main,healthz,k8soidc}.go`,
|
||||
`internal/{mcp,auth,gitea,tools,allowlist,identity,registry,config}`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
@@ -1,11 +1,26 @@
|
||||
module gitea.d-ma.be/mathias/gitea-mcp
|
||||
module git.d-ma.be/mathias/gitea-mcp
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/jwx/v2 v2.1.6 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/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
|
||||
)
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
||||
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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/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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package allowlist
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
)
|
||||
|
||||
type Allowlist struct {
|
||||
owners map[string]struct{}
|
||||
@@ -14,10 +19,17 @@ func New(owners []string) *Allowlist {
|
||||
return &Allowlist{owners: m}
|
||||
}
|
||||
|
||||
func (a *Allowlist) Check(owner string) error {
|
||||
// Check gates owner access to the static list — except for a caller
|
||||
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
|
||||
// access Gitea's own permission model already gates more precisely than a
|
||||
// coarse owner name list ever could.
|
||||
func (a *Allowlist) Check(ctx context.Context, owner string) error {
|
||||
if owner == "" {
|
||||
return fmt.Errorf("owner required")
|
||||
}
|
||||
if _, ok := gitea.TokenFromContext(ctx); ok {
|
||||
return nil
|
||||
}
|
||||
if _, ok := a.owners[owner]; !ok {
|
||||
return fmt.Errorf("owner %q not in allowlist", owner)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
package allowlist_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAllowlistCheck(t *testing.T) {
|
||||
a := allowlist.New([]string{"mathias", "acme"})
|
||||
assert.NoError(t, a.Check("mathias"))
|
||||
assert.NoError(t, a.Check("acme"))
|
||||
assert.Error(t, a.Check("evil"))
|
||||
assert.Error(t, a.Check(""))
|
||||
ctx := context.Background()
|
||||
assert.NoError(t, a.Check(ctx, "mathias"))
|
||||
assert.NoError(t, a.Check(ctx, "acme"))
|
||||
assert.Error(t, a.Check(ctx, "evil"))
|
||||
assert.Error(t, a.Check(ctx, ""))
|
||||
}
|
||||
|
||||
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
|
||||
// is gated by Gitea's own permission model, not the MCP's static owner list —
|
||||
// otherwise a legitimate second user could never touch their own repos.
|
||||
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
|
||||
a := allowlist.New([]string{"mathias"})
|
||||
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
|
||||
assert.NoError(t, a.Check(ctx, "someone-else"))
|
||||
}
|
||||
|
||||
// Empty owner is a structural input error, not an authz question — still
|
||||
// rejected even on the pass-through path.
|
||||
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
|
||||
a := allowlist.New([]string{"mathias"})
|
||||
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
|
||||
assert.Error(t, a.Check(ctx, ""))
|
||||
}
|
||||
|
||||
+31
-5
@@ -2,22 +2,48 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func CallerMiddleware(next http.Handler) http.Handler {
|
||||
// CallerMiddleware extracts the authenticated username from the reverse-proxy
|
||||
// identity headers and stashes it in the request context for Caller().
|
||||
//
|
||||
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
|
||||
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
|
||||
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
|
||||
// convention some setups populate instead; it is used only as a fallback when
|
||||
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
|
||||
// verified X-Auth-Request-User still wins and we log a warning so the
|
||||
// misconfiguration is visible rather than silently resolved (#10).
|
||||
//
|
||||
// logger may be nil, in which case the conflict warning is skipped.
|
||||
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Header.Get("X-Auth-Request-User")
|
||||
authUser := r.Header.Get("X-Auth-Request-User")
|
||||
fwdUser := r.Header.Get("X-Forwarded-User")
|
||||
|
||||
user := authUser
|
||||
if user == "" {
|
||||
user = r.Header.Get("X-Forwarded-User")
|
||||
user = fwdUser
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
|
||||
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
|
||||
"x_auth_request_user", authUser,
|
||||
"x_forwarded_user", fwdUser)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
func withCaller(ctx context.Context, user string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, user)
|
||||
}
|
||||
|
||||
func Caller(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
||||
return v
|
||||
|
||||
@@ -1,26 +1,80 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCallerFromContext(t *testing.T) {
|
||||
called := false
|
||||
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
|
||||
}))
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
|
||||
}
|
||||
|
||||
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
|
||||
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
|
||||
// absent.
|
||||
func TestCallerHeaderPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authReq string
|
||||
forwarded string
|
||||
wantCaller string
|
||||
}{
|
||||
{"auth-request only", "mathiasbq", "", "mathiasbq"},
|
||||
{"forwarded fallback", "", "fwduser", "fwduser"},
|
||||
{"both present, same", "same", "same", "same"},
|
||||
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
|
||||
{"neither", "", "", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got string
|
||||
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
got = auth.Caller(r.Context())
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if tc.authReq != "" {
|
||||
req.Header.Set("X-Auth-Request-User", tc.authReq)
|
||||
}
|
||||
if tc.forwarded != "" {
|
||||
req.Header.Set("X-Forwarded-User", tc.forwarded)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
assert.Equal(t, tc.wantCaller, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// When both headers are present and disagree, a warning is logged so the proxy
|
||||
// misconfiguration is visible rather than silent.
|
||||
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
|
||||
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-Auth-Request-User", "mathiasbq")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
assert.True(t, called)
|
||||
req.Header.Set("X-Auth-Request-User", "authuser")
|
||||
req.Header.Set("X-Forwarded-User", "fwduser")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
logged := buf.String()
|
||||
assert.Contains(t, logged, "conflicting")
|
||||
assert.Contains(t, logged, "authuser")
|
||||
assert.Contains(t, logged, "fwduser")
|
||||
|
||||
// No warning when they agree.
|
||||
buf.Reset()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req2.Header.Set("X-Auth-Request-User", "same")
|
||||
req2.Header.Set("X-Forwarded-User", "same")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req2)
|
||||
assert.Empty(t, buf.String(), "no warning expected when headers agree")
|
||||
}
|
||||
|
||||
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
)
|
||||
|
||||
// TokenValidator asks the upstream service who a bearer token belongs to.
|
||||
type TokenValidator interface {
|
||||
ValidateToken(ctx context.Context, token string) (username string, ok bool)
|
||||
}
|
||||
|
||||
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
|
||||
// if the request's bearer token validates directly against Gitea, it's used
|
||||
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
|
||||
// the server's shared default token. Any other bearer (static token, JWT, or
|
||||
// none) falls through to fallback unchanged — this only adds a capability, it
|
||||
// never removes the existing auth paths.
|
||||
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authz := r.Header.Get("Authorization")
|
||||
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
||||
if hasBearer && token != "" {
|
||||
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
||||
ctx := withCaller(r.Context(), login)
|
||||
ctx = gitea.WithToken(ctx, token)
|
||||
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
fallback.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type fakeValidator struct {
|
||||
login string
|
||||
ok bool
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
|
||||
f.calls++
|
||||
return f.login, f.ok
|
||||
}
|
||||
|
||||
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
|
||||
validator := &fakeValidator{login: "alice", ok: true}
|
||||
var gotCaller string
|
||||
var gotToken string
|
||||
var gotOK bool
|
||||
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotCaller = auth.Caller(r.Context())
|
||||
gotToken, gotOK = gitea.TokenFromContext(r.Context())
|
||||
})
|
||||
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("fallback should not be called for a valid PAT")
|
||||
})
|
||||
|
||||
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer alices-pat")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
assert.Equal(t, "alice", gotCaller)
|
||||
assert.True(t, gotOK)
|
||||
assert.Equal(t, "alices-pat", gotToken)
|
||||
}
|
||||
|
||||
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
|
||||
validator := &fakeValidator{ok: false}
|
||||
fallbackCalled := false
|
||||
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("onValid should not be called for an invalid token")
|
||||
})
|
||||
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
fallbackCalled = true
|
||||
})
|
||||
|
||||
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
assert.True(t, fallbackCalled)
|
||||
}
|
||||
|
||||
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
|
||||
validator := &fakeValidator{ok: true, login: "alice"}
|
||||
fallbackCalled := false
|
||||
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("onValid should not be called with no Authorization header")
|
||||
})
|
||||
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
fallbackCalled = true
|
||||
})
|
||||
|
||||
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
assert.True(t, fallbackCalled)
|
||||
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
|
||||
}
|
||||
@@ -8,18 +8,30 @@ import (
|
||||
type Config struct {
|
||||
Port string // GITEA_MCP_PORT, default 8080
|
||||
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"
|
||||
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
|
||||
K8sIssuerURL string // K8S_ISSUER_URL, in-cluster OIDC issuer for ServiceAccount-token auth (ADR-0011 D1); empty disables
|
||||
K8sAudience string // K8S_MCP_AUDIENCE, required audience claim for k8s SA tokens
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Port: envOr("GITEA_MCP_PORT", "8080"),
|
||||
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")),
|
||||
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"),
|
||||
K8sIssuerURL: os.Getenv("K8S_ISSUER_URL"),
|
||||
K8sAudience: os.Getenv("K8S_MCP_AUDIENCE"),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ package config_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("GITEA_BASE_URL", "")
|
||||
t.Setenv("GITEA_API_TOKEN", "")
|
||||
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
|
||||
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
|
||||
t.Setenv("GITEA_MCP_PORT", "")
|
||||
@@ -23,7 +22,6 @@ func TestLoadDefaults(t *testing.T) {
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
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_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
|
||||
t.Setenv("GITEA_MCP_PORT", "9000")
|
||||
@@ -31,7 +29,6 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
cfg, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
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{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
|
||||
assert.Equal(t, "9000", cfg.Port)
|
||||
|
||||
@@ -3,8 +3,11 @@ package gitea
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
@@ -17,6 +20,21 @@ type Client struct {
|
||||
branchCache *expirable.LRU[string, string]
|
||||
}
|
||||
|
||||
type ctxTokenKey struct{}
|
||||
|
||||
// WithToken overrides the token used for upstream Gitea calls made with the
|
||||
// returned context, taking precedence over the Client's configured default
|
||||
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||
func WithToken(ctx context.Context, token string) context.Context {
|
||||
return context.WithValue(ctx, ctxTokenKey{}, token)
|
||||
}
|
||||
|
||||
// TokenFromContext returns the token set by WithToken, if any.
|
||||
func TokenFromContext(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(ctxTokenKey{}).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
@@ -26,6 +44,23 @@ func NewClient(baseURL, token string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
|
||||
// using that token, not the client's configured default token) and returns
|
||||
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
|
||||
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return "", false
|
||||
}
|
||||
var user struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
|
||||
return "", false
|
||||
}
|
||||
return user.Login, true
|
||||
}
|
||||
|
||||
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||
key := owner + "/" + name
|
||||
@@ -40,7 +75,21 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
|
||||
return repo.DefaultBranch, nil
|
||||
}
|
||||
|
||||
// hasEmptySegment reports whether the path portion (before any query string)
|
||||
// contains an empty segment ("//"), which means an owner or repo path
|
||||
// parameter was empty. Forwarding it upstream yields gitea's opaque
|
||||
// /api/swagger 404 (#36), so callers reject it locally instead.
|
||||
func hasEmptySegment(path string) bool {
|
||||
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
return strings.Contains(path, "//")
|
||||
}
|
||||
|
||||
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||
if hasEmptySegment(path) {
|
||||
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
@@ -49,8 +98,12 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
token := c.token
|
||||
if override, ok := TokenFromContext(ctx); ok {
|
||||
token = override
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -95,6 +148,10 @@ func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, erro
|
||||
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 {
|
||||
Body []byte
|
||||
Status int
|
||||
@@ -102,6 +159,9 @@ type rawResponse struct {
|
||||
}
|
||||
|
||||
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
||||
if hasEmptySegment(path) {
|
||||
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
@@ -110,8 +170,12 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
token := c.token
|
||||
if override, ok := TokenFromContext(ctx); ok {
|
||||
token = override
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #36 second line of defence: when owner or repo is empty the path contains an
|
||||
// empty segment ("//"). Rather than forward it upstream — where gitea answers
|
||||
// with its opaque /api/swagger 404 — the client must reject it locally with a
|
||||
// validation error and never touch the network.
|
||||
func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
|
||||
paths := []string{
|
||||
"/api/v1/repos/mathias//issues", // empty repo
|
||||
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||
}
|
||||
for _, p := range paths {
|
||||
_, _, err := c.GetJSON(context.Background(), p)
|
||||
require.Error(t, err, "path %q should be rejected", p)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "guard must short-circuit before any HTTP call")
|
||||
}
|
||||
|
||||
// A well-formed path with a query string must still pass the guard.
|
||||
func TestWellFormedPathPasses(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, status, err := c.GetJSON(context.Background(), "/api/v1/repos/mathias/gitea-mcp/issues?state=open")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, status)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
||||
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "default-token")
|
||||
ctx := gitea.WithToken(context.Background(), "caller-token")
|
||||
_, status, err := c.GetJSON(ctx, "/api/v1/user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, status)
|
||||
assert.Equal(t, "token caller-token", gotAuth)
|
||||
}
|
||||
|
||||
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "default-token")
|
||||
login, ok := c.ValidateToken(context.Background(), "candidate-token")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "alice", login)
|
||||
}
|
||||
|
||||
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "default-token")
|
||||
login, ok := c.ValidateToken(context.Background(), "bad-token")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, login)
|
||||
}
|
||||
|
||||
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
+135
-15
@@ -1,10 +1,13 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CodeSearchHit struct {
|
||||
@@ -14,30 +17,147 @@ type CodeSearchHit struct {
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
type codeSearchEnvelope struct {
|
||||
Data []CodeSearchHit `json:"data"`
|
||||
OK bool `json:"ok"`
|
||||
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
|
||||
// and scan, so one call against a huge repo can't run indefinitely.
|
||||
const codeSearchMaxFiles = 2000
|
||||
|
||||
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
|
||||
// before any fetch) — almost certainly binary/vendor/generated content, not
|
||||
// worth the cost of fetching just to reject.
|
||||
const codeSearchMaxFileSize = 512 * 1024
|
||||
|
||||
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
|
||||
// obviously-binary content by extension, checked against the tree listing.
|
||||
var codeSearchBinaryExts = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
|
||||
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
|
||||
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
|
||||
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
|
||||
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
|
||||
".pyc": true, ".o": true, ".a": true,
|
||||
}
|
||||
|
||||
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
|
||||
// has no code-content-search endpoint — confirmed against a live 1.25.5
|
||||
// instance's swagger spec (only /repos/search, /repos/issues/search,
|
||||
// /topics/search etc exist). The web UI's OWN code search falls back to
|
||||
// server-side `git grep` because no Repository Indexer is enabled on that
|
||||
// instance, and that fallback is an HTML-only route, not JSON API. So this
|
||||
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
|
||||
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
|
||||
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
|
||||
// user-supplied input) — against file contents.
|
||||
//
|
||||
// Pagination is over the FULL sorted result set, recomputed on every call —
|
||||
// there is no server-side index to page through incrementally, so requesting
|
||||
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
|
||||
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
|
||||
// fix, and is an infra decision, not something gitea-mcp controls.
|
||||
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
||||
if q == "" {
|
||||
return nil, fmt.Errorf("q is required: %w", ErrValidation)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 30
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
|
||||
owner, repo, url.QueryEscape(q), page, limit)
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
|
||||
r, err := c.GetRepo(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
branch := r.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
var env codeSearchEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, err
|
||||
|
||||
tree, err := c.GetTree(ctx, owner, repo, branch, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tree walk: %w", err)
|
||||
}
|
||||
return env.Data, nil
|
||||
|
||||
qLower := strings.ToLower(q)
|
||||
all := make([]CodeSearchHit, 0)
|
||||
scanned := 0
|
||||
for _, e := range tree.Tree {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if e.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
|
||||
continue
|
||||
}
|
||||
if e.Size > codeSearchMaxFileSize {
|
||||
continue
|
||||
}
|
||||
if scanned >= codeSearchMaxFiles {
|
||||
break
|
||||
}
|
||||
scanned++
|
||||
|
||||
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
|
||||
if ferr != nil {
|
||||
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
|
||||
}
|
||||
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.IndexByte(decoded, 0) >= 0 {
|
||||
continue // binary content the extension filter missed
|
||||
}
|
||||
|
||||
content := string(decoded)
|
||||
contentLower := strings.ToLower(content)
|
||||
count := strings.Count(contentLower, qLower)
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.Index(contentLower, qLower)
|
||||
all = append(all, CodeSearchHit{
|
||||
Path: e.Path,
|
||||
Snippet: codeSearchSnippet(content, idx, len(q)),
|
||||
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
|
||||
Score: float64(count),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Score != all[j].Score {
|
||||
return all[i].Score > all[j].Score
|
||||
}
|
||||
return all[i].Path < all[j].Path
|
||||
})
|
||||
|
||||
start := (page - 1) * limit
|
||||
if start >= len(all) {
|
||||
return []CodeSearchHit{}, nil
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[start:end], nil
|
||||
}
|
||||
|
||||
// codeSearchSnippet returns a short window of text centered on a match,
|
||||
// trimmed to a single line-ish window so results read like a grep hit rather
|
||||
// than a content dump.
|
||||
func codeSearchSnippet(content string, idx, matchLen int) string {
|
||||
const window = 60
|
||||
start := idx - window
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + matchLen + window
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
|
||||
return strings.TrimSpace(snippet)
|
||||
}
|
||||
|
||||
@@ -2,31 +2,78 @@ package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSearchCode(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||
|
||||
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
|
||||
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
|
||||
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
|
||||
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
|
||||
// UI's own code search falls back to server-side `git grep`, an HTML-only
|
||||
// route, not JSON API). Records which paths were actually fetched, so tests
|
||||
// can assert a file was SKIPPED (binary ext, oversized) without ever being
|
||||
// read, not just absent from results.
|
||||
type codeSearchFake struct {
|
||||
files map[string]string // path -> content
|
||||
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
|
||||
fetched []string
|
||||
}
|
||||
|
||||
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{
|
||||
"path":"internal/gitea/code_search.go",
|
||||
"snippet":"func (c *Client) SearchCode",
|
||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
|
||||
"score":2.5
|
||||
}],
|
||||
"ok":true
|
||||
}`))
|
||||
}))
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
|
||||
var entries []string
|
||||
for path, content := range f.files {
|
||||
size := int64(len(content))
|
||||
if s, ok := f.sizes[path]; ok {
|
||||
size = s
|
||||
}
|
||||
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
|
||||
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
|
||||
f.fetched = append(f.fetched, path)
|
||||
content, ok := f.files[path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCode_FindsMatchInTree(t *testing.T) {
|
||||
f := &codeSearchFake{files: map[string]string{
|
||||
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
|
||||
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
@@ -34,6 +81,92 @@ func TestSearchCode(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hits, 1)
|
||||
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
||||
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
|
||||
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
|
||||
assert.Contains(t, hits[0].Snippet, "SearchCode")
|
||||
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
|
||||
assert.Equal(t, 1.0, hits[0].Score)
|
||||
}
|
||||
|
||||
func TestSearchCode_CaseInsensitive(t *testing.T) {
|
||||
f := &codeSearchFake{files: map[string]string{
|
||||
"README.md": "# MyProject\n\nBuild instructions here.\n",
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hits, 1)
|
||||
}
|
||||
|
||||
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
|
||||
f := &codeSearchFake{files: map[string]string{
|
||||
"assets/logo.png": "SearchCode", // would match if fetched — must not be
|
||||
"main.go": "package main\n",
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hits)
|
||||
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
|
||||
}
|
||||
|
||||
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
|
||||
f := &codeSearchFake{
|
||||
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
|
||||
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hits)
|
||||
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
|
||||
}
|
||||
|
||||
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
|
||||
f := &codeSearchFake{files: map[string]string{
|
||||
"data.bin": "SearchCode\x00binary-marker",
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
|
||||
}
|
||||
|
||||
func TestSearchCode_Pagination(t *testing.T) {
|
||||
files := map[string]string{}
|
||||
for i := 0; i < 5; i++ {
|
||||
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
|
||||
}
|
||||
f := &codeSearchFake{files: files}
|
||||
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page1, 2)
|
||||
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page2, 2)
|
||||
|
||||
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
|
||||
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
|
||||
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
|
||||
}
|
||||
|
||||
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
|
||||
c := gitea.NewClient("http://unused", "tok")
|
||||
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
+132
-2
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
// Array response means path is a directory — guide caller to dir_list.
|
||||
if len(body) > 0 && body[0] == '[' {
|
||||
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
|
||||
}
|
||||
var fc FileContents
|
||||
if err := json.Unmarshal(body, &fc); err != nil {
|
||||
return nil, err
|
||||
@@ -92,13 +97,138 @@ type FileWriteResult struct {
|
||||
} `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)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -82,6 +82,28 @@ func TestCreateBranchSendsPayload(t *testing.T) {
|
||||
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) {
|
||||
var captured []byte
|
||||
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, "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"])
|
||||
}
|
||||
|
||||
+159
-8
@@ -4,14 +4,31 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
State string `json:"state"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
State string `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Labels []Label `json:"labels"`
|
||||
Assignees []User `json:"assignees"`
|
||||
Comments int `json:"comments"`
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type CreateIssueArgs struct {
|
||||
@@ -22,6 +39,22 @@ type CreateIssueArgs struct {
|
||||
Milestone int64 `json:"milestone,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
payload, err := json.Marshal(args)
|
||||
@@ -42,10 +75,128 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// ListIssuesArgs captures the optional query params for ListIssues.
|
||||
type ListIssuesArgs struct {
|
||||
State string // "open" | "closed" | "all"
|
||||
Labels string // comma-separated label names
|
||||
Since string // ISO 8601
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListIssues fetches issues for a repo. Pulls are excluded server-side
|
||||
// (type=issues) so they don't leak through the same endpoint.
|
||||
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "issues")
|
||||
if args.State != "" {
|
||||
q.Set("state", args.State)
|
||||
}
|
||||
if args.Labels != "" {
|
||||
q.Set("labels", args.Labels)
|
||||
}
|
||||
if args.Since != "" {
|
||||
q.Set("since", args.Since)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var issues []Issue
|
||||
if err := json.Unmarshal(body, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// SetIssueState flips an issue between "open" and "closed" via PATCH.
|
||||
// Gitea uses the same endpoint for both transitions.
|
||||
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
payload, err := json.Marshal(map[string]string{"state": state})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil,
|
||||
// left untouched) from an explicit empty string (clears the field). Maps to
|
||||
// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}.
|
||||
type EditIssueArgs struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// EditIssue patches an issue's title and/or body. Only fields set in args are
|
||||
// sent, so omitted fields are left as-is server-side. Body is sent verbatim —
|
||||
// no identity footer — so repeated edits are idempotent.
|
||||
func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
type IssueComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User User `json:"user,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListIssueComments fetches all comments on an issue or pull request.
|
||||
// Per Gitea, /issues/{index}/comments serves both since PRs share index space with issues.
|
||||
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int) ([]IssueComment, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var comments []IssueComment
|
||||
if err := json.Unmarshal(body, &comments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -45,6 +45,127 @@ func TestCreateIssue(t *testing.T) {
|
||||
assert.Equal(t, "open", iss.State)
|
||||
}
|
||||
|
||||
func TestGetIssue(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 42, iss.Number)
|
||||
assert.Equal(t, "fix auth", iss.Title)
|
||||
assert.Equal(t, "open", iss.State)
|
||||
assert.Equal(t, 3, iss.Comments)
|
||||
}
|
||||
|
||||
func TestGetIssue_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEditIssue(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
title, body := "new title", "new body"
|
||||
iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "new title", payload["title"])
|
||||
assert.Equal(t, "new body", payload["body"])
|
||||
|
||||
assert.Equal(t, 42, iss.Number)
|
||||
assert.Equal(t, "new title", iss.Title)
|
||||
}
|
||||
|
||||
// EditIssue must send only the fields explicitly provided — an omitted field
|
||||
// (nil pointer) is left untouched server-side.
|
||||
func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
title := "only title"
|
||||
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "only title", payload["title"])
|
||||
_, hasBody := payload["body"]
|
||||
assert.False(t, hasBody, "body must be omitted when not set")
|
||||
}
|
||||
|
||||
// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted.
|
||||
func TestEditIssue_EmptyBodyIsSent(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
body := ""
|
||||
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
val, hasBody := payload["body"]
|
||||
assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field")
|
||||
assert.Equal(t, "", val)
|
||||
}
|
||||
|
||||
func TestEditIssue_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
title := "x"
|
||||
_, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestCreateIssueComment(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -70,3 +191,51 @@ func TestCreateIssueComment(t *testing.T) {
|
||||
assert.Equal(t, "hello", comment.Body)
|
||||
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
||||
}
|
||||
|
||||
func TestListIssueComments(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, comments, 2)
|
||||
assert.Equal(t, int64(1), comments[0].ID)
|
||||
assert.Equal(t, "first", comments[0].Body)
|
||||
assert.Equal(t, "alice", comments[0].User.Login)
|
||||
assert.Equal(t, "bob", comments[1].User.Login)
|
||||
}
|
||||
|
||||
func TestListIssueComments_Empty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, comments)
|
||||
}
|
||||
|
||||
func TestListIssueComments_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ListLabels fetches all labels defined on a repo.
|
||||
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var labels []Label
|
||||
if err := json.Unmarshal(body, &labels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
|
||||
// space with issues, per Gitea). This is additive per Gitea's own POST
|
||||
// semantics — existing labels are left in place, no replace/delete needed.
|
||||
// Returns the issue's full label set after the add.
|
||||
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
|
||||
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var labels []Label
|
||||
if err := json.Unmarshal(body, &labels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListLabels(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":1,"name":"bug","color":"ee0701"},
|
||||
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, labels, 2)
|
||||
assert.Equal(t, int64(1), labels[0].ID)
|
||||
assert.Equal(t, "bug", labels[0].Name)
|
||||
assert.Equal(t, "ee0701", labels[0].Color)
|
||||
assert.Equal(t, "enhancement", labels[1].Name)
|
||||
}
|
||||
|
||||
func TestListLabels_Empty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, labels)
|
||||
}
|
||||
|
||||
func TestListLabels_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.ListLabels(context.Background(), "o", "r")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestAddIssueLabels(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":1,"name":"bug","color":"ee0701"},
|
||||
{"id":3,"name":"priority","color":"00ff00"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
ids, ok := payload["labels"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, ids, 1)
|
||||
assert.Equal(t, float64(3), ids[0])
|
||||
|
||||
require.Len(t, labels, 2)
|
||||
assert.Equal(t, "bug", labels[0].Name)
|
||||
assert.Equal(t, "priority", labels[1].Name)
|
||||
}
|
||||
|
||||
func TestAddIssueLabels_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddPushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
|
||||
RemoteAddress: "https://github.com/mathias/infra.git",
|
||||
RemoteUsername: "mathias",
|
||||
RemotePassword: "secret",
|
||||
Interval: "8h0m0s",
|
||||
SyncOnCommit: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mirror-github", m.RemoteName)
|
||||
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
|
||||
}
|
||||
|
||||
func TestListPushMirrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mirrors, 1)
|
||||
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
|
||||
}
|
||||
|
||||
func TestDeletePushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type PullRequest struct {
|
||||
@@ -15,10 +16,12 @@ type PullRequest struct {
|
||||
Draft bool `json:"draft"`
|
||||
Head struct {
|
||||
Ref string `json:"ref"`
|
||||
Sha string `json:"sha"`
|
||||
} `json:"head"`
|
||||
Base struct {
|
||||
Ref string `json:"ref"`
|
||||
} `json:"base"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
}
|
||||
|
||||
type CreatePullRequestArgs struct {
|
||||
@@ -101,3 +104,48 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, ind
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -136,3 +136,55 @@ func TestGetPullRequestDiff(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,109 @@ type Repo struct {
|
||||
Template bool `json:"template"`
|
||||
}
|
||||
|
||||
type TreeEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "blob" or "tree"
|
||||
SHA string `json:"sha"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Tree struct {
|
||||
SHA string `json:"sha"`
|
||||
URL string `json:"url"`
|
||||
Tree []TreeEntry `json:"tree"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
|
||||
if recursive {
|
||||
path += "?recursive=1"
|
||||
}
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t Tree
|
||||
if err := json.Unmarshal(body, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
ID int64 `json:"id"`
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateReleaseArgs struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Draft bool `json:"draft,omitempty"`
|
||||
Prerelease bool `json:"prerelease,omitempty"`
|
||||
// Target branch or commit SHA for tag creation. Empty = repo default branch.
|
||||
Target string `json:"target_commitish,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner, repo)
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Release
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
|
||||
resp, status, err := c.DeleteJSON(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == 204 {
|
||||
return nil
|
||||
}
|
||||
return MapStatus(status, resp)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
||||
body, err := json.Marshal(map[string][]string{"topics": topics})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, status, err := c.PutJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == 204 {
|
||||
return nil
|
||||
}
|
||||
return MapStatus(status, resp)
|
||||
}
|
||||
|
||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -71,6 +174,72 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
|
||||
return env.Data, nil
|
||||
}
|
||||
|
||||
type CreateRepoArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
AutoInit bool `json:"auto_init,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
// Org, when non-empty, creates the repo under the named organisation.
|
||||
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
||||
Org string `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
||||
var path string
|
||||
if args.Org != "" {
|
||||
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
||||
} else {
|
||||
path = "/api/v1/user/repos"
|
||||
}
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
||||
type UpdateRepoArgs struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Template *bool `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
@@ -86,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -47,6 +47,129 @@ func TestListRepos(t *testing.T) {
|
||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||
}
|
||||
|
||||
func TestCreateRepo_User(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||
Name: "infra",
|
||||
Private: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mathias/infra", r.FullName)
|
||||
assert.Equal(t, "main", r.DefaultBranch)
|
||||
}
|
||||
|
||||
func TestCreateRepo_Org(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||
Name: "infra",
|
||||
Org: "hyperguild",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||
}
|
||||
|
||||
func TestUpdateRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
desc := "updated"
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
|
||||
Description: &desc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated", r.Description)
|
||||
}
|
||||
|
||||
func TestGetTree(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc", tree.SHA)
|
||||
require.Len(t, tree.Tree, 2)
|
||||
assert.Equal(t, "README.md", tree.Tree[0].Path)
|
||||
assert.Equal(t, "blob", tree.Tree[0].Type)
|
||||
assert.Equal(t, int64(13), tree.Tree[0].Size)
|
||||
}
|
||||
|
||||
func TestUpdateTopics(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPut, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRelease(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||
TagName: "v1.0.0",
|
||||
Name: "v1.0.0",
|
||||
Body: "first release",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||
assert.Equal(t, "first release", rel.Body)
|
||||
}
|
||||
|
||||
func TestDeleteRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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"])
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
+81
-31
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
|
||||
@@ -14,51 +14,45 @@ type DispatchWorkflowArgs struct {
|
||||
Inputs map[string]any `json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowRunTrigger holds the run ID extracted from the Location header.
|
||||
type WorkflowRunTrigger struct {
|
||||
RunID int64
|
||||
}
|
||||
|
||||
// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID.
|
||||
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) {
|
||||
// DispatchWorkflow triggers a workflow_dispatch event. Gitea returns 204 No
|
||||
// Content with NO Location header, so the response carries no run ID — callers
|
||||
// resolve the new run separately via ListWorkflowRuns. Returns nil on success.
|
||||
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) error {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
resp, err := c.doRaw(ctx, "POST", p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if resp.Status != 204 {
|
||||
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
|
||||
return nil, mapErr
|
||||
return mapErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status %d", resp.Status)
|
||||
return fmt.Errorf("unexpected status %d", resp.Status)
|
||||
}
|
||||
location := resp.Headers.Get("Location")
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("missing Location header in dispatch response")
|
||||
}
|
||||
// Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment.
|
||||
parts := strings.Split(strings.TrimRight(location, "/"), "/")
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("malformed Location: %s", location)
|
||||
}
|
||||
runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse run id from %q: %w", location, err)
|
||||
}
|
||||
return &WorkflowRunTrigger{RunID: runID}, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkflowRun represents a Gitea Actions run.
|
||||
type WorkflowRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"` // queued | in_progress | completed
|
||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||
StartedAt string `json:"started_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title,omitempty"`
|
||||
Status string `json:"status"` // queued | in_progress | completed
|
||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||
Event string `json:"event,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
HeadBranch string `json:"head_branch,omitempty"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
RunNumber int64 `json:"run_number,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
// GetWorkflowRun fetches the status of a specific Actions run.
|
||||
@@ -77,3 +71,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
|
||||
}
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
|
||||
type ListWorkflowRunsArgs struct {
|
||||
Branch string
|
||||
HeadSHA string
|
||||
Status string // queued | in_progress | completed | all
|
||||
Event string // push | pull_request | schedule | workflow_dispatch | all
|
||||
Workflow string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type workflowRunsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
|
||||
}
|
||||
|
||||
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
|
||||
// Status / Event of "all" or "" are treated as no-filter.
|
||||
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
|
||||
q := url.Values{}
|
||||
if args.Branch != "" {
|
||||
q.Set("branch", args.Branch)
|
||||
}
|
||||
if args.HeadSHA != "" {
|
||||
q.Set("head_sha", args.HeadSHA)
|
||||
}
|
||||
if args.Status != "" && args.Status != "all" {
|
||||
q.Set("status", args.Status)
|
||||
}
|
||||
if args.Event != "" && args.Event != "all" {
|
||||
q.Set("event", args.Event)
|
||||
}
|
||||
if args.Workflow != "" {
|
||||
q.Set("workflow", args.Workflow)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp workflowRunsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Gitea's dispatch endpoint returns 204 No Content with NO Location header.
|
||||
// Dispatch must succeed and forward ref+inputs in the body; the run ID is
|
||||
// resolved separately by the tool via ListWorkflowRuns.
|
||||
func TestDispatchWorkflow(t *testing.T) {
|
||||
var gotBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -22,18 +25,17 @@ func TestDispatchWorkflow(t *testing.T) {
|
||||
var err error
|
||||
gotBody, err = io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789")
|
||||
// No Location header — matches real Gitea.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
|
||||
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
|
||||
Ref: "main",
|
||||
Inputs: map[string]any{"env": "prod"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(789), result.RunID)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(gotBody, &body))
|
||||
@@ -43,19 +45,6 @@ func TestDispatchWorkflow(t *testing.T) {
|
||||
assert.Equal(t, "prod", inputs["env"])
|
||||
}
|
||||
|
||||
func TestDispatchWorkflowMissingLocation(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 204 but no Location header
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Location")
|
||||
}
|
||||
|
||||
func TestDispatchWorkflowError404(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -63,7 +52,7 @@ func TestDispatchWorkflowError404(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
||||
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gitea.ErrNotFound))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package identity_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
+13
-13
@@ -5,11 +5,11 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolVersion = "2025-06-18"
|
||||
ProtocolVersion = "2025-03-26"
|
||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
@@ -31,6 +31,9 @@ func NewServer(opts ServerOptions) *Server {
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
w.Header().Set("MCP-Protocol-Version", ProtocolVersion)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
s.handleGET(w, r)
|
||||
case http.MethodPost:
|
||||
@@ -53,7 +56,6 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// initialize is the only method allowed without a session.
|
||||
if req.Method == "initialize" {
|
||||
sid := s.opts.Sessions.Issue()
|
||||
w.Header().Set("Mcp-Session-Id", sid)
|
||||
@@ -65,11 +67,12 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.Header.Get("Mcp-Session-Id")
|
||||
if !s.opts.Sessions.Valid(sid) {
|
||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Mcp-Session-Id is advisory: we issue one on initialize and accept it back,
|
||||
// but every tool the gitea-mcp server exposes is stateless single-shot, so
|
||||
// we do not gate non-initialize calls on it. The claude.ai connector's
|
||||
// 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 {
|
||||
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) {
|
||||
sid := r.Header.Get("Mcp-Session-Id")
|
||||
if !s.opts.Sessions.Valid(sid) {
|
||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Session ID is optional for GET: clients may open the SSE stream before
|
||||
// calling initialize (e.g. claude.ai probes on add). Accept with or without.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -52,19 +52,27 @@ func TestInitialize(t *testing.T) {
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
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)
|
||||
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)
|
||||
rr := postJSON(t, srv, map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"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) {
|
||||
@@ -118,6 +126,15 @@ func TestPostBodyTooLarge(t *testing.T) {
|
||||
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) {
|
||||
srv := newServer(t)
|
||||
// Initialize to get a session ID.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #38: `repo` (and `number`) are the canonical, idiomatic gitea/GitHub arg names
|
||||
// that identifier tools advertise. `name` is kept as a back-compat alias via the
|
||||
// bidirectional shim, so old `name` callers still work. `index`->`number` stays.
|
||||
// An explicit canonical (`repo`) always wins over its alias (`name`).
|
||||
func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantPath string
|
||||
}{
|
||||
{"canonical repo+number", `{"owner":"mathias","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||
{"repo+index alias", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||
{"legacy name alias", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||
{"explicit repo wins over name", `{"owner":"mathias","name":"ignored","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":7,"title":"t"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.args))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantPath, gotPath)
|
||||
assert.Contains(t, string(out), `"number":7`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// #38: identifier tools must ADVERTISE `repo` (not `name`) in their schema, so
|
||||
// the contract a client reads matches what every caller sends. The two create
|
||||
// tools keep `name` because there it means "name of the new repo", not an
|
||||
// existing-repo identifier.
|
||||
func TestRepoIsCanonicalInAdvertisedSchema(t *testing.T) {
|
||||
c := gitea.NewClient("http://unused", "")
|
||||
a := allowlist.New([]string{"mathias"})
|
||||
|
||||
identifier := map[string]json.RawMessage{
|
||||
"repo_get": tools.NewRepoGet(c, a).Descriptor().InputSchema,
|
||||
"issue_get": tools.NewIssueGet(c, a).Descriptor().InputSchema,
|
||||
"pr_merge": tools.NewPRMerge(c, a).Descriptor().InputSchema,
|
||||
"repo_delete": tools.NewRepoDelete(c, a).Descriptor().InputSchema,
|
||||
"file_read": tools.NewFileRead(c, a).Descriptor().InputSchema,
|
||||
}
|
||||
for name, sch := range identifier {
|
||||
s := string(sch)
|
||||
assert.Contains(t, s, `"repo":`, name+" must advertise repo")
|
||||
assert.NotContains(t, s, `"name":`, name+" must not advertise name")
|
||||
}
|
||||
|
||||
// create tools keep `name` (new resource name, not an existing-repo id)
|
||||
assert.Contains(t, string(tools.NewRepoCreate(c, a).Descriptor().InputSchema), `"name":`)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"repo":{"type":"string"},
|
||||
"branch":{"type":"string"}
|
||||
},
|
||||
"required":["owner","repo","branch"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
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(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Branch == "" {
|
||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
if err := t.c.DeleteBranch(ctx, args.Owner, args.Repo, args.Branch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"deleted": true,
|
||||
"branch": args.Branch,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"repo":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","repo"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
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(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches, err := t.c.ListBranches(ctx, args.Owner, args.Repo, 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)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"repo":{"type":"string"},
|
||||
"branch":{"type":"string"}
|
||||
},
|
||||
"required":["owner","repo","branch"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchProtectionGetArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
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(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type semaphore chan struct{}
|
||||
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
|
||||
type codeSearchArgs struct {
|
||||
Q string `json:"q"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Repo string `json:"repo,omitempty"` // optional: empty => owner-wide fan-out (#37 opt-out)
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
||||
if args.Q == "" {
|
||||
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Page < 1 {
|
||||
|
||||
@@ -2,36 +2,92 @@ package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
|
||||
// for a set of repos — the real endpoints code_search's underlying SearchCode
|
||||
// now uses (Gitea's REST API has no code-content-search endpoint; see
|
||||
// internal/gitea/code_search.go's doc comment).
|
||||
type multiRepoSearchFake struct {
|
||||
owner string
|
||||
repos []string // ListRepos response, in this order
|
||||
files map[string]map[string]string // repo -> path -> content
|
||||
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
|
||||
}
|
||||
|
||||
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{
|
||||
"path":"internal/gitea/repos.go",
|
||||
"snippet":"func (c *Client) ListRepos",
|
||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
||||
"score":3.0
|
||||
}],
|
||||
"ok":true
|
||||
}`))
|
||||
}))
|
||||
p := r.URL.Path
|
||||
|
||||
for _, repo := range f.repos {
|
||||
base := "/api/v1/repos/" + f.owner + "/" + repo
|
||||
switch {
|
||||
case p == base && f.fail[repo]:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||
return
|
||||
case p == base:
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
|
||||
return
|
||||
case strings.HasPrefix(p, base+"/git/trees/"):
|
||||
var entries []string
|
||||
for path := range f.files[repo] {
|
||||
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||
return
|
||||
case strings.HasPrefix(p, base+"/contents/"):
|
||||
path := strings.TrimPrefix(p, base+"/contents/")
|
||||
content, ok := f.files[repo][path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if p == "/api/v1/users/"+f.owner+"/repos" {
|
||||
var entries []string
|
||||
for _, repo := range f.repos {
|
||||
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
|
||||
return
|
||||
}
|
||||
|
||||
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
require.Len(t, result.Results, 1)
|
||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
||||
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
|
||||
}
|
||||
|
||||
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra", "gitea-mcp"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"main.go": "this is an infra hit\n"},
|
||||
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/broken/search":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"infra", "broken"},
|
||||
files: map[string]map[string]string{
|
||||
"infra": {"main.go": "this is an infra hit\n"},
|
||||
},
|
||||
fail: map[string]bool{"broken": true},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
@@ -134,9 +175,11 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
PartialRepos []string `json:"partial_repos"`
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
} `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
PartialRepos []string `json:"partial_repos"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.True(t, result.Partial)
|
||||
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
||||
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/alpha/search":
|
||||
// low score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/beta/search":
|
||||
// high score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
f := &multiRepoSearchFake{
|
||||
owner: "mathias",
|
||||
repos: []string{"alpha", "beta"},
|
||||
files: map[string]map[string]string{
|
||||
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
|
||||
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result.Results, 2)
|
||||
// First result must be the high-score one
|
||||
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
|
||||
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||
"expected results sorted by score desc, got %v then %v",
|
||||
result.Results[0].Score, result.Results[1].Score)
|
||||
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
||||
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
|
||||
}
|
||||
|
||||
@@ -2,34 +2,37 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||
|
||||
var substitutionFiles = []string{
|
||||
"go.mod",
|
||||
"Taskfile.yml",
|
||||
"Dockerfile",
|
||||
".gitea/workflows/cd.yml",
|
||||
"README.md",
|
||||
".context/PROJECT.md",
|
||||
}
|
||||
|
||||
func substitutions(owner, name string) map[string]string {
|
||||
return map[string]string{
|
||||
"__PROJECT_NAME__": name,
|
||||
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
||||
// git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be
|
||||
// rename; a stale host breaks `go mod download` for downstream consumers).
|
||||
"__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name,
|
||||
}
|
||||
}
|
||||
|
||||
func applyReplacements(s string, repls map[string]string) string {
|
||||
for k, v := range repls {
|
||||
s = strings.ReplaceAll(s, k, v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||
type CreateProjectFromTemplate struct {
|
||||
c *gitea.Client
|
||||
@@ -45,14 +48,17 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "create_project_from_template",
|
||||
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
|
||||
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean"}
|
||||
"private":{"type":"boolean"},
|
||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
|
||||
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file (dispatch#3) AND register owner/name into mathias/dispatch's git-tracked allowlist (dispatch-repos.txt, dispatch#19) — both gates are required for the watcher to actually pick the repo up; each is applied independently and idempotently. Default false. Safe to re-request on resume."},
|
||||
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
@@ -60,21 +66,44 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||
}
|
||||
|
||||
type createProjectArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
TemplateName string `json:"template_name"`
|
||||
DispatchAllow bool `json:"dispatch_allow"`
|
||||
Resume bool `json:"resume"`
|
||||
}
|
||||
|
||||
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
||||
// sandbox convention: presence of the file (not its content) marks the repo
|
||||
// dispatch-eligible; the comment exists only to explain that to a human reader.
|
||||
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
|
||||
"# See dispatch#3.\n"
|
||||
|
||||
type createProjectResult struct {
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
|
||||
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
|
||||
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
|
||||
}
|
||||
|
||||
// The dispatch allowlist (dispatch#19) is a fixed integration point — a
|
||||
// specific file in a specific repo the mathias/dispatch watcher reads at the
|
||||
// start of every cycle. Hardcoded to match its own DISPATCH_ALLOWLIST_OWNER/
|
||||
// _REPO/_PATH defaults (unconfigured in the live CronJob, confirmed 2026-07-06).
|
||||
const (
|
||||
dispatchAllowlistOwner = "mathias"
|
||||
dispatchAllowlistRepo = "dispatch"
|
||||
dispatchAllowlistPath = "dispatch-repos.txt"
|
||||
dispatchAllowlistBranch = "main"
|
||||
)
|
||||
|
||||
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args createProjectArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
@@ -82,7 +111,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
}
|
||||
|
||||
// Allowlist check first.
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -91,24 +120,121 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// Verify template exists and is marked as a template repo.
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||
var result createProjectResult
|
||||
var branch string
|
||||
var err error
|
||||
if args.Resume {
|
||||
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
|
||||
} else {
|
||||
result, branch, err = t.createDestination(ctx, args)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template lookup: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
||||
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
||||
// A fixed known-files list can't rename directories or cover every templated
|
||||
// file, which is why the old scaffold didn't build. GetTree reflects the
|
||||
// branch's CURRENT state, which is what makes this loop safe to re-run on
|
||||
// resume: a file already fixed in a prior partial pass shows up already-correct
|
||||
// (or already-renamed) and substituteEntry is a no-op for it.
|
||||
repls := substitutions(args.Owner, args.Name)
|
||||
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
||||
if terr != nil {
|
||||
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
|
||||
return textOK(result)
|
||||
}
|
||||
|
||||
for _, e := range tree.Tree {
|
||||
if e.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
||||
if fail != "" {
|
||||
result.PartialFailure = fail
|
||||
break
|
||||
}
|
||||
if substituted != "" {
|
||||
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
||||
}
|
||||
}
|
||||
|
||||
// Opt the new project into headless dispatch if asked. Two INDEPENDENT gates,
|
||||
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
|
||||
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
|
||||
// allowlist. Skip both if substitution itself already stalled — don't mark an
|
||||
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
|
||||
// (gitea-mcp#51/#54) — never conflated with each other or with substitution,
|
||||
// since any one of the three can fail independently of the other two.
|
||||
if args.DispatchAllow && result.PartialFailure == "" {
|
||||
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
|
||||
result.DispatchAllowFailure = fail
|
||||
} else if didWrite {
|
||||
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
|
||||
}
|
||||
|
||||
ownerName := args.Owner + "/" + args.Name
|
||||
if didRegister, fail := t.registerDispatchAllowlist(ctx, ownerName); fail != "" {
|
||||
result.DispatchAllowlistFailure = fail
|
||||
} else if didRegister {
|
||||
result.DispatchAllowlisted = true
|
||||
}
|
||||
}
|
||||
|
||||
// If substitution stalled because the generated branch wasn't writable in time,
|
||||
// the repo IS created — say so clearly and point to the concrete recovery step
|
||||
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
|
||||
// gitea's template-generate is slow-async on this instance, so tool-side
|
||||
// substitution is best-effort).
|
||||
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
||||
strings.Contains(result.PartialFailure, "not found") {
|
||||
result.PartialFailure = infra179FinalizeMessage(
|
||||
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
||||
}
|
||||
|
||||
// Fail loud on a FRESH create with nothing substituted: a real template
|
||||
// should have placeholders, so finding none is suspicious. On resume, nothing
|
||||
// left to substitute is the expected steady state once a prior partial run is
|
||||
// fully caught up — success, not a loud failure.
|
||||
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
||||
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
||||
}
|
||||
|
||||
return textOK(result)
|
||||
}
|
||||
|
||||
// createDestination generates a new repo from the template: verifies the
|
||||
// template exists and the destination doesn't already exist, then calls
|
||||
// gitea's /generate and resolves the default branch.
|
||||
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
|
||||
// Resolve template: per-call override takes precedence over the
|
||||
// server-configured default. Owner stays server-configured.
|
||||
tmplName := args.TemplateName
|
||||
if tmplName == "" {
|
||||
tmplName = t.templateName
|
||||
}
|
||||
|
||||
// Verify template exists and is marked as a template repo.
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||
if err != nil {
|
||||
return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
|
||||
}
|
||||
if !tmpl.Template {
|
||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
|
||||
return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// Verify destination doesn't already exist.
|
||||
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
|
||||
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
|
||||
return createProjectResult{}, "", fmt.Errorf(
|
||||
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
|
||||
args.Owner, args.Name, gitea.ErrConflict)
|
||||
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||
return nil, fmt.Errorf("destination check: %w", err)
|
||||
return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
|
||||
}
|
||||
|
||||
// Generate repo from template.
|
||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
||||
Owner: args.Owner,
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
@@ -116,7 +242,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
GitContent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate: %w", err)
|
||||
return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
|
||||
}
|
||||
|
||||
result := createProjectResult{
|
||||
@@ -126,21 +252,236 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
DefaultBranch: newRepo.DefaultBranch,
|
||||
}
|
||||
|
||||
// Substitute placeholders in known files (best-effort).
|
||||
repls := substitutions(args.Owner, args.Name)
|
||||
// The /generate response often omits default_branch — resolve it explicitly,
|
||||
// otherwise every file read below hits an empty ref and nothing substitutes
|
||||
// (the silent-null bug: gitea-mcp#42).
|
||||
branch := newRepo.DefaultBranch
|
||||
for _, path := range substitutionFiles {
|
||||
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
||||
// Files that don't exist in this template are silently skipped.
|
||||
if errors.Is(err, gitea.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
// Any other error halts the substitution pass with partial_failure recorded.
|
||||
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
||||
break
|
||||
if branch == "" {
|
||||
if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
|
||||
branch = r.DefaultBranch
|
||||
} else {
|
||||
branch = "main"
|
||||
}
|
||||
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
||||
}
|
||||
result.DefaultBranch = branch
|
||||
return result, branch, nil
|
||||
}
|
||||
|
||||
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
|
||||
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
|
||||
// nothing to resume without it. Template lookup and generation are skipped
|
||||
// entirely: resume only ever operates on the destination.
|
||||
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
|
||||
repo, err := t.c.GetRepo(ctx, owner, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrNotFound) {
|
||||
return createProjectResult{}, "", fmt.Errorf(
|
||||
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
|
||||
owner, name, gitea.ErrValidation)
|
||||
}
|
||||
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
|
||||
}
|
||||
branch := repo.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
return createProjectResult{
|
||||
FullName: repo.FullName,
|
||||
HTMLURL: repo.HTMLURL,
|
||||
CloneURL: repo.CloneURL,
|
||||
DefaultBranch: branch,
|
||||
}, branch, nil
|
||||
}
|
||||
|
||||
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
|
||||
// on every resume, not just the first attempt): creates it if absent, updates
|
||||
// it if present but different, leaves it untouched if already correct. Without
|
||||
// this, a naive create-only write would error on a re-invoke (gitea rejects a
|
||||
// create at a path that already exists) — that was the reported failure in
|
||||
// gitea-mcp#51. Returns whether a write actually happened.
|
||||
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
|
||||
const path = ".dispatch-allow"
|
||||
sha := ""
|
||||
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
|
||||
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
|
||||
return false, "" // already present and correct — idempotent no-op
|
||||
}
|
||||
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
|
||||
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||
return false, fmt.Sprintf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
return textOK(result)
|
||||
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||
Branch: branch,
|
||||
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
||||
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
||||
Sha: sha,
|
||||
}); err != nil {
|
||||
return false, fmt.Sprintf("write %s: %v", path, err)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// registerDispatchAllowlist appends ownerName ("owner/name") to
|
||||
// mathias/dispatch's git-tracked dispatch-repos.txt if not already listed
|
||||
// (gitea-mcp#54) — idempotent, safe to call on every resume. This is the
|
||||
// SECOND of the two required dispatch gates: being listed here is necessary
|
||||
// but not sufficient on its own (the repo also needs its own .dispatch-allow
|
||||
// marker, injectDispatchAllow's job) — dispatch#3's structural trust-zone
|
||||
// design requires both independently. Returns whether a write actually
|
||||
// happened, so the caller only reports a fresh registration, not a no-op.
|
||||
func (t *CreateProjectFromTemplate) registerDispatchAllowlist(ctx context.Context, ownerName string) (didRegister bool, failure string) {
|
||||
fc, err := t.c.GetFileContents(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, dispatchAllowlistBranch)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("read %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("decode %s: %v", dispatchAllowlistPath, err)
|
||||
}
|
||||
content := string(decoded)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.TrimSpace(line) == ownerName {
|
||||
return false, "" // already listed — idempotent no-op
|
||||
}
|
||||
}
|
||||
|
||||
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
|
||||
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
|
||||
Branch: dispatchAllowlistBranch,
|
||||
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
|
||||
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
|
||||
Sha: fc.Sha,
|
||||
}); err != nil {
|
||||
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
||||
// async template-generate (infra#179) leaves the branch unwritable within the
|
||||
// budget. It points at the concrete recovery step — re-invoking this same tool
|
||||
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
|
||||
// resume safely continues from wherever substitution stalled.
|
||||
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
||||
return fmt.Sprintf(
|
||||
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
||||
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
||||
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
|
||||
"(same owner/name) once the branch is writable — it safely continues where this left "+
|
||||
"off, skipping anything already correct. Underlying: %s",
|
||||
branch, budget, done, underlying)
|
||||
}
|
||||
|
||||
// substitutionBudget bounds how long we retry the first write while the freshly
|
||||
// generated branch becomes writable. gitea's /generate returns (and serves reads)
|
||||
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
||||
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
||||
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
||||
// fails fast with partial_failure naming resume=true as the recovery path
|
||||
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
|
||||
const substitutionBudget = 5
|
||||
|
||||
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
||||
// exist" not-found, up to substitutionBudget. The write itself is the readiness
|
||||
// probe — BranchExists reports the branch present before writes succeed.
|
||||
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
|
||||
var err error
|
||||
for i := 0; i < substitutionBudget; i++ {
|
||||
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gitea.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
||||
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
||||
// content in place when changed. Returns a human-readable description of what was
|
||||
// substituted ("" if nothing), and a non-empty partial-failure string on error.
|
||||
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
|
||||
newPath := applyReplacements(path, repls)
|
||||
|
||||
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrNotFound) {
|
||||
return "", "" // vanished between tree walk and read; skip
|
||||
}
|
||||
return "", fmt.Sprintf("read %s: %v", path, err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||
if err != nil {
|
||||
return "", fmt.Sprintf("decode %s: %v", path, err)
|
||||
}
|
||||
newContent := applyReplacements(string(decoded), repls)
|
||||
renamed := newPath != path
|
||||
changed := newContent != string(decoded)
|
||||
if !renamed && !changed {
|
||||
return "", "" // nothing to do
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
||||
|
||||
if renamed {
|
||||
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
|
||||
}
|
||||
|
||||
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||
Branch: branch,
|
||||
Content: enc,
|
||||
Message: "template: substitute placeholders",
|
||||
Sha: fc.Sha,
|
||||
}); err != nil {
|
||||
return "", fmt.Sprintf("write %s: %v", path, err)
|
||||
}
|
||||
return path, ""
|
||||
}
|
||||
|
||||
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
|
||||
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
|
||||
// are separate, non-atomic API calls) completes cleanly instead of erroring on
|
||||
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
|
||||
// already holds the correct content, the write is skipped and only the
|
||||
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
|
||||
// is a no-op too.
|
||||
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
|
||||
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
|
||||
switch {
|
||||
case err == nil:
|
||||
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
|
||||
if derr == nil && string(decoded) == newContent {
|
||||
break // already correct from a prior partial run — skip the write
|
||||
}
|
||||
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||
Branch: branch, Content: enc, Sha: existing.Sha,
|
||||
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
|
||||
}); writeErr != nil {
|
||||
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
|
||||
}
|
||||
case errors.Is(err, gitea.ErrNotFound):
|
||||
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||
Branch: branch, Content: enc,
|
||||
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
|
||||
}); writeErr != nil {
|
||||
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Sprintf("read %s: %v", newPath, err)
|
||||
}
|
||||
|
||||
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
|
||||
Branch: branch,
|
||||
Sha: oldSha,
|
||||
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
|
||||
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
|
||||
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
|
||||
}
|
||||
return oldPath + " -> " + newPath, ""
|
||||
}
|
||||
|
||||
@@ -5,262 +5,572 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
||||
var substitutionFileList = []string{
|
||||
"go.mod",
|
||||
"Taskfile.yml",
|
||||
"Dockerfile",
|
||||
".gitea/workflows/cd.yml",
|
||||
"README.md",
|
||||
".context/PROJECT.md",
|
||||
}
|
||||
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||
|
||||
// contentWithPlaceholder is a template file body that contains the placeholder.
|
||||
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
||||
|
||||
func encodedContent(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// fileContentsJSON returns a JSON FileContents object for the given path.
|
||||
func fileContentsJSON(path string) string {
|
||||
enc := encodedContent(contentWithPlaceholder)
|
||||
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
||||
path, strings.ReplaceAll(path, "/", "-"), enc)
|
||||
}
|
||||
|
||||
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
||||
func fileWriteResultJSON(path string) string {
|
||||
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
||||
}
|
||||
|
||||
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
||||
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||
func templateRepoJSON(name string, isTemplate bool) string {
|
||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||
name, name, name, name, isTemplate)
|
||||
}
|
||||
|
||||
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
||||
func newGeneratedRepoJSON(name string) string {
|
||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||
name, name, name, name)
|
||||
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
|
||||
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
|
||||
type fakeTemplateServer struct {
|
||||
mu sync.Mutex
|
||||
files map[string]string // path -> raw (un-substituted) content
|
||||
genBranch string // default_branch returned by /generate ("" to force fallback)
|
||||
generated bool
|
||||
generateCalls int // # times POST .../generate was hit — resume must never increment this
|
||||
puts []string
|
||||
deletes []string
|
||||
putBodies map[string]string // path -> decoded written content
|
||||
repoGetsPost int // GET dest after generate (branch fallback)
|
||||
|
||||
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
|
||||
// "" (default) 404s the read, matching a repo that hasn't seeded the file
|
||||
// (a distinct error path); tests that care set it explicitly.
|
||||
dispatchRepos string
|
||||
dispatchReposPuts int
|
||||
}
|
||||
|
||||
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
||||
c := gitea.NewClient(srvURL, "tok")
|
||||
a := allowlist.New([]string{"mathias"})
|
||||
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
||||
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
||||
}
|
||||
|
||||
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
||||
func TestCreateProjectHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// newFakeTemplateServerResumed simulates a repo that already exists from a
|
||||
// prior (real) generate call — GET dest succeeds immediately, without a
|
||||
// /generate call first. Used for resume:true tests.
|
||||
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||
f := newFakeTemplateServer(files, genBranch)
|
||||
f.generated = true
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
p := r.URL.Path
|
||||
|
||||
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
|
||||
switch {
|
||||
// Template repo lookup
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
case r.Method == http.MethodGet && p == dispatchReposPath:
|
||||
if f.dispatchRepos == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos))
|
||||
|
||||
// Destination repo lookup — 404 means it doesn't exist yet
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
// Generate
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||
|
||||
// File contents GET — handle all 6 substitution files
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
// File contents PUT — handle all 6 substitution files
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
case r.Method == http.MethodPut && p == dispatchReposPath:
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var args struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &args)
|
||||
dec, _ := base64.StdEncoding.DecodeString(args.Content)
|
||||
f.dispatchRepos = string(dec)
|
||||
f.dispatchReposPuts++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||
_, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`))
|
||||
|
||||
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
||||
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
||||
|
||||
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
|
||||
if !f.generated {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
f.repoGetsPost++
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
|
||||
|
||||
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
||||
f.generated = true
|
||||
f.generateCalls++
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||
dest, dest, f.genBranch, dest, dest)
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
|
||||
var entries []string
|
||||
for path := range f.files {
|
||||
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
|
||||
}
|
||||
// include a tree (directory) entry to exercise the blob filter
|
||||
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
|
||||
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||
body, ok := f.files[path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
|
||||
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
|
||||
|
||||
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
|
||||
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var args struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &args)
|
||||
dec, _ := base64.StdEncoding.DecodeString(args.Content)
|
||||
f.puts = append(f.puts, path)
|
||||
f.putBodies[path] = string(dec)
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
|
||||
|
||||
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||
f.deletes = append(f.deletes, path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate {
|
||||
return tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
|
||||
}
|
||||
|
||||
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
|
||||
t.Helper()
|
||||
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
|
||||
require.NoError(t, err)
|
||||
var out createOut
|
||||
require.NoError(t, json.Unmarshal(res, &out))
|
||||
return out
|
||||
}
|
||||
|
||||
type createOut struct {
|
||||
FullName string `json:"full_name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
|
||||
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
|
||||
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
|
||||
}
|
||||
|
||||
// Happy path: whole-tree substitution, content + path rename, correct module host.
|
||||
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"go.mod": "module __MODULE_PATH__\n\ngo 1.26\n",
|
||||
"README.md": "# __PROJECT_NAME__\n",
|
||||
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||
"pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched
|
||||
}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
||||
require.NoError(t, err)
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||
|
||||
var out struct {
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
assert.Equal(t, "mathias/new-svc", out.FullName)
|
||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
||||
assert.Equal(t, "main", out.DefaultBranch)
|
||||
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
|
||||
// content-substituted files present; untouched file absent
|
||||
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||
assert.Contains(t, out.FilesSubstituted, "README.md")
|
||||
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
|
||||
// path rename recorded as "old -> new"
|
||||
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||
|
||||
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
|
||||
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
|
||||
// rename: new path written, old path deleted
|
||||
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
|
||||
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
|
||||
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||
f.putBodies["cmd/new-svc/main.go"])
|
||||
// the untouched file was never written
|
||||
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
|
||||
}
|
||||
|
||||
// The /generate response omits default_branch (the live gitea behavior the old
|
||||
// mock hid) → tool must re-fetch the repo and still substitute.
|
||||
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||
|
||||
assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
|
||||
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
|
||||
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
}
|
||||
|
||||
// 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"}`))
|
||||
// Fail loud: a template whose files carry no placeholders yields nothing
|
||||
// substituted — surface it rather than returning silent success.
|
||||
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
|
||||
files := map[string]string{"README.md": "# static, no placeholders\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||
assert.Empty(t, out.FilesSubstituted)
|
||||
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
|
||||
}
|
||||
|
||||
// Write failure mid-pass → partial_failure populated, no Go error.
|
||||
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
base := f.handler(t, "template-go-agent", "new-svc")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||
return
|
||||
}
|
||||
base(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||
assert.NotEmpty(t, out.PartialFailure)
|
||||
assert.Contains(t, out.PartialFailure, "go.mod")
|
||||
}
|
||||
|
||||
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
|
||||
func TestCreateProject_DispatchAllow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
argsJSON string
|
||||
wantFile bool
|
||||
}{
|
||||
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
|
||||
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
|
||||
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON)
|
||||
require.Empty(t, out.PartialFailure)
|
||||
if tc.wantFile {
|
||||
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||
assert.Contains(t, f.puts, ".dispatch-allow")
|
||||
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
|
||||
} else {
|
||||
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||
assert.NotContains(t, f.puts, ".dispatch-allow")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
|
||||
|
||||
// resume:true requires an ALREADY-CREATED destination — there is nothing to
|
||||
// resume otherwise.
|
||||
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
|
||||
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Contains(t, err.Error(), "nothing to resume")
|
||||
}
|
||||
|
||||
// resume:true on an existing repo continues substitution — fixes only what's
|
||||
// still wrong, leaves already-correct files untouched, and never calls
|
||||
// /generate again (the whole point: no re-creation, just continuation).
|
||||
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
|
||||
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
|
||||
}
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.Equal(t, 0, f.generateCalls, "resume must never call /generate")
|
||||
assert.Contains(t, out.FilesSubstituted, "README.md")
|
||||
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
|
||||
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
|
||||
assert.Contains(t, f.puts, "README.md")
|
||||
}
|
||||
|
||||
// resume:true when everything is already substituted is the expected steady
|
||||
// state (a prior resume already finished the job, or this is a redundant
|
||||
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
|
||||
// that a fresh (non-resume) create would trigger.
|
||||
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
|
||||
"README.md": "# new-svc\n",
|
||||
}
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||
|
||||
assert.Empty(t, out.FilesSubstituted)
|
||||
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
|
||||
}
|
||||
|
||||
// A resume where a prior partial run's rename write SUCCEEDED but its paired
|
||||
// delete FAILED (both are separate, non-atomic API calls) must complete
|
||||
// cleanly: recognize the new path is already correct, skip re-writing it, and
|
||||
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
|
||||
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
|
||||
files := map[string]string{
|
||||
// stray: delete never completed in the prior run
|
||||
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||
// already correct: the write half of the same prior rename DID complete
|
||||
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||
}
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
|
||||
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
|
||||
}
|
||||
|
||||
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
|
||||
// has the correct content (from an earlier successful injection), re-invoking
|
||||
// must not attempt another write — and must not error the way a naive
|
||||
// create-only write would (gitea 409/422 on an existing path with no sha).
|
||||
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
|
||||
// Phase 1: a normal (non-resume) call captures the REAL content the tool
|
||||
// writes for .dispatch-allow, without the test needing to know the exact
|
||||
// unexported constant.
|
||||
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
seedSrv := newFakeTemplateServer(seedFiles, "main")
|
||||
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
|
||||
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||
srv1.Close()
|
||||
require.Empty(t, out1.PartialFailure)
|
||||
realContent := seedSrv.putBodies[".dispatch-allow"]
|
||||
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
|
||||
|
||||
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
|
||||
// one file still needing substitution.
|
||||
files := map[string]string{
|
||||
".dispatch-allow": realContent,
|
||||
"README.md": "# __PROJECT_NAME__\n",
|
||||
}
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv2.Close()
|
||||
|
||||
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
|
||||
|
||||
assert.Empty(t, out2.PartialFailure)
|
||||
assert.Empty(t, out2.DispatchAllowFailure)
|
||||
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
|
||||
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
|
||||
assert.Contains(t, out2.FilesSubstituted, "README.md")
|
||||
}
|
||||
|
||||
// dispatch_allow injection failing is reported in its OWN field, distinct from
|
||||
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
|
||||
// eligibility still fails, and the caller must be able to tell them apart.
|
||||
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
base := f.handler(t, "template-go-agent", "new-svc")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||
return
|
||||
}
|
||||
base(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure")
|
||||
assert.NotEmpty(t, out.DispatchAllowFailure)
|
||||
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
|
||||
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
|
||||
}
|
||||
|
||||
// dispatch_allow now ALSO registers the new repo into mathias/dispatch's
|
||||
// git-tracked allowlist (dispatch-repos.txt) — the second of the two required
|
||||
// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54).
|
||||
func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n"
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.Empty(t, out.DispatchAllowlistFailure)
|
||||
assert.True(t, out.DispatchAllowlisted)
|
||||
assert.Equal(t, 1, f.dispatchReposPuts)
|
||||
assert.Contains(t, f.dispatchRepos, "mathias/new-svc")
|
||||
// existing entries preserved, not clobbered
|
||||
assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox")
|
||||
assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo")
|
||||
}
|
||||
|
||||
// Registering is idempotent: a repo already listed (e.g. a resume re-running
|
||||
// with dispatch_allow:true) must not produce a duplicate line or a redundant write.
|
||||
func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n"
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.Empty(t, out.DispatchAllowlistFailure)
|
||||
assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration")
|
||||
assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write")
|
||||
}
|
||||
|
||||
// A failure registering the allowlist is reported in its OWN field — distinct
|
||||
// from PartialFailure (substitution) AND DispatchAllowFailure (the marker
|
||||
// file) — since all three are independent gates that can fail independently.
|
||||
func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
f.dispatchRepos = "mathias/dispatch-sandbox\n"
|
||||
base := f.handler(t, "template-go-agent", "new-svc")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||
return
|
||||
}
|
||||
base(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure, "substitution succeeded — must not be conflated")
|
||||
assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated")
|
||||
assert.NotEmpty(t, out.DispatchAllowlistFailure)
|
||||
assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt")
|
||||
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure")
|
||||
}
|
||||
|
||||
// dispatch_allow=false/omitted must never touch the allowlist file at all.
|
||||
func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(t *testing.T) {
|
||||
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||
f := newFakeTemplateServer(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.False(t, out.DispatchAllowlisted)
|
||||
assert.Equal(t, 0, f.dispatchReposPuts)
|
||||
}
|
||||
|
||||
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||
|
||||
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||
_, err := tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
||||
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
"mathias", "template-go-web",
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||
func TestCreateProject_AllowlistRejects(t *testing.T) {
|
||||
_, err := tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "allowlist")
|
||||
}
|
||||
|
||||
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
||||
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
||||
func TestCreateProject_NotTemplate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Template lookup returns a non-template repo.
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
||||
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
|
||||
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestCreateProjectDestinationExists: destination repo already exists.
|
||||
func TestCreateProjectDestinationExists(t *testing.T) {
|
||||
func TestCreateProject_DestinationExists(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||
// Destination exists — return 200.
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/mathias/template-go-agent":
|
||||
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
|
||||
case "/api/v1/repos/mathias/new-svc":
|
||||
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||
}
|
||||
|
||||
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
|
||||
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
|
||||
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
|
||||
// Files that should succeed (index 0-2 in substitutionFileList).
|
||||
successFiles := map[string]bool{
|
||||
"go.mod": true,
|
||||
"Taskfile.yml": true,
|
||||
"Dockerfile": true,
|
||||
}
|
||||
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
|
||||
failFile := ".gitea/workflows/cd.yml"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
if filePath == failFile {
|
||||
// Simulate upstream 500.
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||
return
|
||||
}
|
||||
if !successFiles[filePath] {
|
||||
t.Errorf("unexpected PUT for file: %s", filePath)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
// Best-effort: no Go error returned, partial state in result.
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
FullName string `json:"full_name"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
// First 3 files should be in FilesSubstituted.
|
||||
assert.Len(t, out.FilesSubstituted, 3)
|
||||
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
|
||||
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
|
||||
assert.NotContains(t, out.FilesSubstituted, failFile)
|
||||
|
||||
// partial_failure should be non-empty.
|
||||
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #46: the infra#179 finalize guidance must not point at a non-existent command
|
||||
// (`hyperguild new-project` was never built). #50 superseded the original
|
||||
// manual-editing guidance with a concrete, real recovery: re-invoke this same
|
||||
// tool with resume=true.
|
||||
func TestInfra179FinalizeMessage(t *testing.T) {
|
||||
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
|
||||
|
||||
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("message missing %q\ngot: %s", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "hyperguild new-project") {
|
||||
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"repo":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"ref":{"type":"string"}
|
||||
},
|
||||
"required":["owner","repo"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type dirListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
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(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := t.c.ListContents(ctx, args.Owner, args.Repo, 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)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"repo":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"branch":{"type":"string"},
|
||||
"message":{"type":"string"},
|
||||
"sha":{"type":"string"}
|
||||
},
|
||||
"required":["owner","repo","path","branch","message","sha"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type fileDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
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(ctx, 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.Repo, 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
const fileReadMaxBytes = 1 << 20 // 1 MiB
|
||||
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"ref":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","path"]
|
||||
"required":["owner","repo","path"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type fileReadArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
@@ -51,20 +51,20 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref := args.Ref
|
||||
if ref == "" {
|
||||
var err error
|
||||
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
|
||||
assert.Equal(t, "main", result["ref"])
|
||||
}
|
||||
|
||||
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Gitea returns an array when path is a directory
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "directory")
|
||||
assert.Contains(t, err.Error(), "dir_list")
|
||||
}
|
||||
|
||||
func TestFileReadAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type FileWriteBranch struct {
|
||||
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
|
||||
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "file_write_branch",
|
||||
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
|
||||
Description: "Create or update a file on the given branch. Pass an existing branch — e.g. the default branch `main` — to commit directly to it (trunk-based); a branch that doesn't exist yet is created from `base` first (PR flow). Pair with pr_create/pr_merge for the branch→PR→merge path.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"content":{"type":"string"},
|
||||
"branch":{"type":"string"},
|
||||
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
||||
"message":{"type":"string"},
|
||||
"sha":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","path","content","branch","message"]
|
||||
"required":["owner","repo","path","content","branch","message"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type fileWriteBranchArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Branch string `json:"branch"`
|
||||
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Branch == "" {
|
||||
@@ -68,7 +68,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
||||
}
|
||||
|
||||
// Resolve base default if branch needs to be created
|
||||
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
|
||||
exists, err := t.c.BranchExists(ctx, args.Owner, args.Repo, args.Branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
||||
base := args.Base
|
||||
if base == "" {
|
||||
var err error
|
||||
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, args.Branch, base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
|
||||
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
|
||||
result, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
|
||||
Branch: args.Branch,
|
||||
Content: encoded,
|
||||
Message: args.Message,
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -39,9 +39,9 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
||||
_, _ = 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) {
|
||||
require.Equal(t, http.MethodPut, r.Method)
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(upsertFileResp))
|
||||
})
|
||||
@@ -64,6 +64,39 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
||||
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) {
|
||||
var createBody []byte
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueClose struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
|
||||
return &IssueClose{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_close",
|
||||
Description: "Close an open issue. Reversible via issue_reopen.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCloseArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueCloseArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueCloseTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
assert.JSONEq(t, `{"state":"closed"}`, string(b))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":26`)
|
||||
assert.Contains(t, string(out), `"state":"closed"`)
|
||||
}
|
||||
|
||||
func TestIssueCloseTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueCloseAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueComment struct {
|
||||
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1},
|
||||
"body":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","number","body"]
|
||||
"required":["owner","repo","number","body"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCommentArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
||||
}
|
||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||
|
||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueCreate struct {
|
||||
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"title":{"type":"string"},
|
||||
"body":{"type":"string"},
|
||||
"labels":{"type":"array","items":{"type":"integer"}},
|
||||
"assignees":{"type":"array","items":{"type":"string"}},
|
||||
"milestone":{"type":"integer"}
|
||||
},
|
||||
"required":["owner","name","title"]
|
||||
"required":["owner","repo","title"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []int64 `json:"labels"`
|
||||
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Title == "" {
|
||||
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
||||
}
|
||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||
|
||||
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
|
||||
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Repo, gitea.CreateIssueArgs{
|
||||
Title: args.Title,
|
||||
Body: body,
|
||||
Labels: args.Labels,
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueEdit struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit {
|
||||
return &IssueEdit{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_edit",
|
||||
Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " +
|
||||
"omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " +
|
||||
"WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " +
|
||||
"(fetch with issue_get, edit the text, send it back).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1},
|
||||
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
|
||||
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueEditArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueEditArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Title == nil && args.Body == nil {
|
||||
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
iss, err := t.c.EditIssue(ctx, args.Owner, args.Repo, args.Number, gitea.EditIssueArgs{
|
||||
Title: args.Title,
|
||||
Body: args.Body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"number": iss.Number,
|
||||
"title": iss.Title,
|
||||
"html_url": iss.HTMLURL,
|
||||
"state": iss.State,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueEditTool(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "new", payload["title"])
|
||||
assert.Equal(t, "updated body", payload["body"])
|
||||
|
||||
assert.Contains(t, string(out), `"number":26`)
|
||||
}
|
||||
|
||||
// Body must be sent verbatim — no identity footer appended (keeps edits idempotent).
|
||||
func TestIssueEditTool_BodyVerbatim(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"state":"open"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer")
|
||||
_, hasTitle := payload["title"]
|
||||
assert.False(t, hasTitle, "title must be omitted when not provided")
|
||||
}
|
||||
|
||||
func TestIssueEditTool_RequiresAField(t *testing.T) {
|
||||
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
func TestIssueEditTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
title := "x"
|
||||
body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title})
|
||||
_, err := tool.Call(context.Background(), body)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueEditAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueGet struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
|
||||
|
||||
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_get",
|
||||
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueGetArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueGetArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueGetTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":42`)
|
||||
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||
assert.Contains(t, string(out), `"comments":3`)
|
||||
}
|
||||
|
||||
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueLabel struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
|
||||
return &IssueLabel{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_label",
|
||||
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1},
|
||||
"labels":{"type":"array","items":{"type":"string"},"description":"Label names to resolve and apply. Either labels or label_ids is required."},
|
||||
"label_ids":{"type":"array","items":{"type":"integer"},"description":"Label IDs to apply directly, skipping name resolution. Either labels or label_ids is required."}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueLabelArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
LabelIDs []int64 `json:"label_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueLabelArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||
}
|
||||
if len(args.Labels) == 0 && len(args.LabelIDs) == 0 {
|
||||
return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
ids := append([]int64{}, args.LabelIDs...)
|
||||
if len(args.Labels) > 0 {
|
||||
existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]int64, len(existing))
|
||||
for _, l := range existing {
|
||||
byName[l.Name] = l.ID
|
||||
}
|
||||
for _, name := range args.Labels {
|
||||
id, ok := byName[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(labels)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const labelListFixture = `[
|
||||
{"id":1,"name":"bug","color":"ee0701"},
|
||||
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||
]`
|
||||
|
||||
func TestIssueLabelAppliesByName(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(labelListFixture))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(labelListFixture))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
ids, ok := payload["labels"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
|
||||
|
||||
assert.Contains(t, string(out), `"name":"bug"`)
|
||||
assert.Contains(t, string(out), `"name":"enhancement"`)
|
||||
}
|
||||
|
||||
// label_ids alone (no labels) must work end-to-end without hitting ListLabels
|
||||
// at all — this is the schema-level "either labels or label_ids" contract, and
|
||||
// it must never require a GET to the label list when the caller already has IDs.
|
||||
func TestIssueLabelAppliesByIDOnly(t *testing.T) {
|
||||
var captured []byte
|
||||
var listCalled bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
|
||||
listCalled = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(labelListFixture))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(labelListFixture))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"label_ids":[1,2]}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, listCalled, "label_ids-only must not call ListLabels")
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
ids, ok := payload["labels"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
|
||||
assert.Contains(t, string(out), `"name":"bug"`)
|
||||
}
|
||||
|
||||
// #52 review finding: the advertised schema wrongly required "labels", making
|
||||
// label_ids-only calls fail JSON-Schema validation before reaching Call at all.
|
||||
// Lock the fixed contract: neither is individually required.
|
||||
func TestIssueLabelSchema_NeitherLabelsNorLabelIDsRequired(t *testing.T) {
|
||||
sch := string(tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})).Descriptor().InputSchema)
|
||||
assert.NotContains(t, sch, `"required":["owner","repo","number","labels"]`)
|
||||
assert.Contains(t, sch, `"required":["owner","repo","number"]`)
|
||||
}
|
||||
|
||||
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(labelListFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
assert.Contains(t, err.Error(), `"does-not-exist"`)
|
||||
assert.Contains(t, err.Error(), "o/r")
|
||||
}
|
||||
|
||||
func TestIssueLabelAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueLabelRequiresValidNumber(t *testing.T) {
|
||||
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
|
||||
return &IssueList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_list",
|
||||
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"state":{"type":"string","enum":["open","closed","all"]},
|
||||
"labels":{"type":"string"},
|
||||
"since":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","repo"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
State string `json:"state"`
|
||||
Labels string `json:"labels"`
|
||||
Since string `json:"since"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.State == "" {
|
||||
args.State = "open"
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
issues, err := t.c.ListIssues(ctx, args.Owner, args.Repo, gitea.ListIssuesArgs{
|
||||
State: args.State,
|
||||
Labels: args.Labels,
|
||||
Since: args.Since,
|
||||
Page: args.Page,
|
||||
Limit: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"issues": issues,
|
||||
}
|
||||
if len(issues) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueListComments struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueListComments(c *gitea.Client, a *allowlist.Allowlist) *IssueListComments {
|
||||
return &IssueListComments{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_list_comments",
|
||||
Description: "List all comments on an issue or pull request. Returns id, body, author, html_url, and timestamps for each comment.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueListCommentsArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueListCommentsArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(comments)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueListCommentsTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42/comments", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":1,"body":"first","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||
{"id":2,"body":"second","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"id":1`)
|
||||
assert.Contains(t, string(out), `"body":"first"`)
|
||||
assert.Contains(t, string(out), `"login":"alice"`)
|
||||
assert.Contains(t, string(out), `"login":"bob"`)
|
||||
}
|
||||
|
||||
func TestIssueListCommentsTool_Empty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `[]`)
|
||||
}
|
||||
|
||||
func TestIssueListCommentsTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueListCommentsAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueListComments(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
|
||||
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"number":42`)
|
||||
assert.Contains(t, out, `"number":41`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state filter",
|
||||
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "closed"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "label + since filter",
|
||||
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
|
||||
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "empty result",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"state": "open"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
assert.NotContains(t, out, `next_page`)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||
require.NoError(t, err)
|
||||
tc.assert(t, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueReopen struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
|
||||
return &IssueReopen{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_reopen",
|
||||
Description: "Reopen a closed issue. Reversible via issue_close.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","repo","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueReopenArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueReopenArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueReopenTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
assert.JSONEq(t, `{"state":"open"}`, string(b))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":26`)
|
||||
assert.Contains(t, string(out), `"state":"open"`)
|
||||
}
|
||||
|
||||
func TestIssueReopenAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type LabelList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
|
||||
return &LabelList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *LabelList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "label_list",
|
||||
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"}
|
||||
},
|
||||
"required":["owner","repo"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type labelListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args labelListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(labels)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user