Compare commits
77 Commits
43e016e8fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
c345025221 | ||
|
|
64559f0250 | ||
|
|
b8463d66a0 | ||
|
|
d261a9f3fe | ||
|
|
09b3f25211 | ||
|
|
a8d04242d9 | ||
|
|
1f9934349b | ||
|
|
4274b48ea5 | ||
|
|
fb473262ba | ||
|
|
39dc22ec3a | ||
|
|
e95e87e8e3 | ||
|
|
d3d0fed6b1 | ||
|
|
c8a353aa35 | ||
|
|
6f43ff216f | ||
|
|
2c6b9986e4 | ||
|
|
e4a9d058f0 | ||
|
|
61cce37ff5 |
2
.aider.conf.yml
Normal file
2
.aider.conf.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
read: .aider.conventions.md
|
||||
auto-commits: false
|
||||
340
.aider.conventions.md
Normal file
340
.aider.conventions.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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://gitea.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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: gitea-mcp
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
107
.context/PROJECT.md
Normal file
107
.context/PROJECT.md
Normal file
@@ -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://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
25
.context/mcp.json
Normal file
25
.context/mcp.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
347
.context/system-prompt.txt
Normal file
347
.context/system-prompt.txt
Normal file
@@ -0,0 +1,347 @@
|
||||
You are a coding assistant working on a specific project.
|
||||
Follow all conventions from both the root agent context and project context.
|
||||
|
||||
---
|
||||
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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://gitea.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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: gitea-mcp
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
|
||||
---
|
||||
343
.cursorrules
Normal file
343
.cursorrules
Normal file
@@ -0,0 +1,343 @@
|
||||
# Cursor rules — auto-generated
|
||||
# Do not edit. Run: task context:sync
|
||||
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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://gitea.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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: gitea-mcp
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
183
.gitea/workflows/cd.yml
Normal file
183
.gitea/workflows/cd.yml
Normal file
@@ -0,0 +1,183 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
IMAGE: gitea-mcp
|
||||
|
||||
jobs:
|
||||
# ── 1. Quality gate ─────────────────────────────────────────────────────────
|
||||
check:
|
||||
name: Lint / Test / Vet
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false # self-hosted runner: Go cache persists on disk between runs
|
||||
|
||||
- name: Verify toolchain
|
||||
run: |
|
||||
go version
|
||||
task --version
|
||||
govulncheck -version 2>&1 || true
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
|
||||
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
|
||||
golangci-lint --version
|
||||
|
||||
- name: Run checks
|
||||
run: task check
|
||||
|
||||
# ── 2. Build image ──────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build & Import
|
||||
needs: check
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image tags
|
||||
id: meta
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
||||
REF="${{ github.ref }}"
|
||||
if [[ "$REF" == refs/tags/v* ]]; then
|
||||
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and push to local registry
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
buildah build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
||||
-t ${REF} \
|
||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
||||
.
|
||||
buildah push --tls-verify=false ${REF}
|
||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
||||
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
|
||||
buildah push --tls-verify=false \
|
||||
${REF} \
|
||||
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
||||
echo "✓ Image pushed to ${REF}"
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
|
||||
sudo k3s ctr images pull --plain-http ${REF}
|
||||
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
|
||||
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
|
||||
echo "$OUTPUT" | grep -q "gitea-mcp" \
|
||||
&& echo "✓ Smoke test passed" \
|
||||
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
|
||||
|
||||
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy via GitOps
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Update image tag in infra repo
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
|
||||
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
||||
chmod 600 ~/.ssh/id_infra
|
||||
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
|
||||
rm -rf /tmp/infra
|
||||
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
|
||||
cd /tmp/infra
|
||||
|
||||
DEPLOYMENT="k3s/apps/gitea-mcp/deployment.yaml"
|
||||
sed -i "s|image: localhost:5000/gitea-mcp:.*|image: localhost:5000/gitea-mcp:${IMAGE_TAG}|" "$DEPLOYMENT"
|
||||
|
||||
grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" "$DEPLOYMENT" \
|
||||
|| { echo "✗ image tag patch failed"; exit 1; }
|
||||
|
||||
if git diff --quiet "$DEPLOYMENT"; then
|
||||
echo "ℹ image tag unchanged — skipping push"
|
||||
else
|
||||
git -c user.name="gitea-mcp CI" \
|
||||
-c user.email="ci@gitea-mcp.local" \
|
||||
commit -m "chore(deploy): gitea-mcp → ${IMAGE_TAG}" "$DEPLOYMENT"
|
||||
git push origin main
|
||||
echo "✓ pushed to infra repo"
|
||||
fi
|
||||
|
||||
shred -u ~/.ssh/id_infra
|
||||
|
||||
- name: Trigger Flux reconcile (immediate)
|
||||
run: |
|
||||
kubectl -n flux-system annotate gitrepository flux-system \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
kubectl -n flux-system annotate kustomization apps \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
|
||||
- name: Wait for Flux to apply new image
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
|
||||
run: |
|
||||
EXPECTED="localhost:5000/gitea-mcp:${IMAGE_TAG}"
|
||||
for i in $(seq 1 60); do
|
||||
CURRENT=$(kubectl get deploy gitea-mcp -n gitea-mcp \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
||||
echo "✓ Flux applied new image after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kubectl get deploy gitea-mcp -n gitea-mcp \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
||||
| grep -qx "$EXPECTED" \
|
||||
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
|
||||
|
||||
- name: Verify rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/gitea-mcp \
|
||||
--namespace gitea-mcp \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
echo "── pod status ──"
|
||||
kubectl get pods -n gitea-mcp -o wide
|
||||
echo "── events ──"
|
||||
kubectl get events -n gitea-mcp --sort-by='.lastTimestamp' | tail -20
|
||||
echo "── describe ──"
|
||||
kubectl describe pods -n gitea-mcp -l app=gitea-mcp | tail -40
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Confirm pod running new image
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
|
||||
run: |
|
||||
kubectl get pods -n gitea-mcp \
|
||||
-l app=gitea-mcp \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[*].spec.containers[0].image}' \
|
||||
| grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" \
|
||||
&& echo "✓ pod running new image" \
|
||||
|| { echo "✗ pod image mismatch"; exit 1; }
|
||||
5
.githooks/pre-push
Normal file
5
.githooks/pre-push
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "→ Running task check before push..."
|
||||
task check
|
||||
echo "✓ pre-push check passed"
|
||||
42
.skills/go-patterns/SKILL.md
Normal file
42
.skills/go-patterns/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: go-patterns
|
||||
description: Go project patterns — endpoint checklist, error handling, HTMX responses, dependency policy. Use when writing Go code, adding endpoints, or reviewing Go PRs.
|
||||
---
|
||||
|
||||
# Go project patterns
|
||||
|
||||
## New endpoint checklist
|
||||
1. Define request/response types in `types.go`
|
||||
2. Write handler in `handlers.go` using `http.HandlerFunc`
|
||||
3. Add route in `routes.go`
|
||||
4. Write table-driven test in `handlers_test.go`
|
||||
5. Run `task check` before committing
|
||||
|
||||
## Error handling pattern
|
||||
```go
|
||||
if err != nil {
|
||||
return fmt.Errorf("descriptiveOperation: %w", err)
|
||||
}
|
||||
```
|
||||
Never log and return — do one or the other.
|
||||
|
||||
## HTMX response pattern
|
||||
```go
|
||||
func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to list items", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
h.templates.Render(w, "items/_list", items)
|
||||
return
|
||||
}
|
||||
h.templates.Render(w, "items/index", items)
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency policy
|
||||
- Prefer stdlib: `net/http`, `encoding/json`, `database/sql`
|
||||
- Allowed without justification: `testify`, `slog`, `templ`, `sqlc`
|
||||
- Needs justification in commit message: anything else
|
||||
31
.skills/htmx-patterns/SKILL.md
Normal file
31
.skills/htmx-patterns/SKILL.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: htmx-patterns
|
||||
description: HTMX conventions — default attributes, form patterns, validation errors, hypermedia-first API design. Use when writing HTMX templates or Go handlers that return HTML fragments.
|
||||
---
|
||||
|
||||
# HTMX patterns
|
||||
|
||||
## Default attributes
|
||||
Always include on interactive elements:
|
||||
- `hx-indicator` for loading states
|
||||
- `hx-swap="innerHTML"` as default (explicit over implicit)
|
||||
- `hx-target` pointing to a specific ID, never `this` in production
|
||||
|
||||
## Form pattern
|
||||
```html
|
||||
<form hx-post="/items" hx-target="#item-list" hx-swap="beforeend" hx-indicator="#spinner">
|
||||
<input type="text" name="title" required>
|
||||
<button type="submit">Add</button>
|
||||
<span id="spinner" class="htmx-indicator">...</span>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Server-sent validation errors
|
||||
Return 422 with the error fragment, swap into the form's error container:
|
||||
```html
|
||||
hx-target-422="#form-errors"
|
||||
```
|
||||
|
||||
## Prefer hypermedia over JSON
|
||||
If the endpoint returns data for display, return an HTML fragment.
|
||||
Only use JSON for machine-to-machine APIs or when a non-browser client needs it.
|
||||
340
AGENTS.md
Normal file
340
AGENTS.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Agent context — Mathias workspace
|
||||
|
||||
<!-- Canonical root context for all AI coding agents.
|
||||
Lives at: ~/dev/.context/AGENT.md
|
||||
Applies to every project under ~/dev/ unless overridden.
|
||||
|
||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||
|
||||
## Who I am
|
||||
|
||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||
I build software, research emerging tech, and deliver consulting engagements
|
||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||
and climate/sustainability tech.
|
||||
|
||||
## How I work with agents
|
||||
|
||||
- I think like a product manager — I care about *why* before *how*
|
||||
- I want agents to be opinionated and push back, not just execute blindly
|
||||
- I prefer concise responses; skip ceremony and get to the point
|
||||
- When I say "build this", I mean production-quality with tests, not a demo
|
||||
- Ask me before making irreversible changes or adding heavy dependencies
|
||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||
|
||||
## Behavior rules
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||
files, and formatting alone. Diffs should be small and reviewable.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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://gitea.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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||
|
||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||
|
||||
Key skills:
|
||||
- **TDD**: always write tests first — load `tdd` skill
|
||||
- **Code Review**: load `code-review` skill before any review
|
||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||
|
||||
---
|
||||
|
||||
# Project context
|
||||
|
||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||
prepended for harnesses that don't walk the directory tree. -->
|
||||
|
||||
## Identity
|
||||
|
||||
- **Name**: gitea-mcp
|
||||
- **Owner**: Mathias
|
||||
- **Client**: personal
|
||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
@@ -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://gitea.d-ma.be/mathias/gitea-mcp
|
||||
- **Status**: active
|
||||
|
||||
## Stack
|
||||
|
||||
- **Primary language**: Go
|
||||
- **UI layer**: HTMX + Templ (when applicable)
|
||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||
- **Build**: Task (taskfile.dev), not Make
|
||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||
|
||||
### Architecture preferences
|
||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||
- Dependency injection via constructor functions, not containers
|
||||
- Configuration via environment variables, parsed at startup into a typed struct
|
||||
- Structured logging via `slog`
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- **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.
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going
|
||||
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||
# the module proxy + sumdb.
|
||||
RUN apk add --no-cache git && \
|
||||
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
||||
ENV GOPRIVATE=gitea.d-ma.be
|
||||
ENV GOPROXY=direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/gitea-mcp /gitea-mcp
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/gitea-mcp"]
|
||||
11
README.md
11
README.md
@@ -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.
|
||||
|
||||
46
Taskfile.yml
46
Taskfile.yml
@@ -14,3 +14,49 @@ tasks:
|
||||
lint:
|
||||
desc: Run golangci-lint
|
||||
cmds: [golangci-lint run ./...]
|
||||
vet:
|
||||
cmds:
|
||||
- go vet ./...
|
||||
- govulncheck ./... || true
|
||||
|
||||
check:
|
||||
desc: Run all checks (context freshness + lint + test + vet)
|
||||
cmds:
|
||||
- cmd: |
|
||||
if [ -n "${CI:-}" ]; then
|
||||
echo "✓ context sync: skipped in CI"
|
||||
else
|
||||
bash scripts/context-sync.sh
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
echo "$drift" >&2
|
||||
echo "" >&2
|
||||
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ context: canonical and adapters are in sync"
|
||||
fi
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
|
||||
context:sync:
|
||||
desc: Regenerate all harness-specific context files
|
||||
cmds:
|
||||
- bash scripts/context-sync.sh
|
||||
|
||||
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]
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
@@ -23,30 +27,81 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
|
||||
ctx := context.Background()
|
||||
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||
if err != nil {
|
||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||
|
||||
reg := registry.New()
|
||||
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueClose(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||
|
||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||
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.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||
auth.CallerMiddleware(mcpSrv),
|
||||
),
|
||||
))
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
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")
|
||||
|
||||
2450
docs/superpowers/plans/2026-05-06-gitops-agent-tools.md
Normal file
2450
docs/superpowers/plans/2026-05-06-gitops-agent-tools.md
Normal file
File diff suppressed because it is too large
Load Diff
169
docs/superpowers/specs/2026-05-06-gitops-agent-tools-design.md
Normal file
169
docs/superpowers/specs/2026-05-06-gitops-agent-tools-design.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# GitOps Agent Tools — Design Spec
|
||||
|
||||
**Date:** 2026-05-06
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the Gitea MCP server with the tools an AI agent needs to drive a full GitOps development loop autonomously — reading repo state, deciding on a branching strategy, making changes, opening and merging PRs, and tagging releases — without any local git tooling.
|
||||
|
||||
The agent selects between feature-branch and trunk-based development based on branch protection rules it reads at runtime.
|
||||
|
||||
---
|
||||
|
||||
## New Tools (9)
|
||||
|
||||
All tools follow the existing pattern: one file in `internal/tools/`, one Gitea client method in `internal/gitea/`, allowlist check on `owner`, table-driven tests in both packages.
|
||||
|
||||
### `repo_status`
|
||||
|
||||
Convenience read tool — returns branch list, open PRs, and protection info for a target branch in a single call. Designed for the agent's first query on any repo so it can decide its strategy.
|
||||
|
||||
**Inputs:** `owner`, `name`, `branch` (optional — defaults to repo default branch)
|
||||
**Output:** `{ branches: [...], open_prs: [...], protection: { protected, required_approvals, push_whitelist, merge_whitelist } }`
|
||||
**Implementation:** calls `ListBranches` + `ListPullRequests(state=open)` + `GetBranchProtection` internally, composes result. No new Gitea API surface.
|
||||
|
||||
---
|
||||
|
||||
### `branch_list`
|
||||
|
||||
**Inputs:** `owner`, `name`, `page` (optional), `limit` (optional, default 30)
|
||||
**Output:** array of `{ name, sha }`
|
||||
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/branches`
|
||||
|
||||
---
|
||||
|
||||
### `branch_delete`
|
||||
|
||||
**Inputs:** `owner`, `name`, `branch`
|
||||
**Output:** confirmation message
|
||||
**Gitea endpoint:** `DELETE /api/v1/repos/{owner}/{repo}/branches/{branch}`
|
||||
**Error handling:** 403 from Gitea (protected branch) surfaced as a descriptive error.
|
||||
|
||||
---
|
||||
|
||||
### `branch_protection_get`
|
||||
|
||||
**Inputs:** `owner`, `name`, `branch`
|
||||
**Output:** `{ protected, required_approvals, push_whitelist, merge_whitelist }`
|
||||
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/branch_protections/{branch}`
|
||||
**Error handling:** 404 → return `{ protected: false }`, not an error. Allows agent to make clean boolean decisions.
|
||||
|
||||
---
|
||||
|
||||
### `pr_list`
|
||||
|
||||
**Inputs:** `owner`, `name`, `state` (`open`/`closed`/`all`, default `open`), `head` (optional branch filter), `page`, `limit`
|
||||
**Output:** array of `{ number, title, state, head_branch, base_branch, draft, html_url }`
|
||||
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/pulls`
|
||||
|
||||
---
|
||||
|
||||
### `pr_merge`
|
||||
|
||||
**Inputs:** `owner`, `name`, `index`, `style` (`merge`/`squash`/`rebase`, default `merge`), `merge_message_title` (optional), `merge_message_field` (optional)
|
||||
**Output:** `{ merged: true, commit_sha }` — if Gitea returns 204 No Content (some merge styles), output is `{ merged: true }` without `commit_sha`.
|
||||
**Gitea endpoint:** `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge`
|
||||
**Error handling:** 405 (checks failing) and 409 (merge conflict) passed through with the Gitea error message intact so the agent understands why it failed.
|
||||
|
||||
---
|
||||
|
||||
### `dir_list`
|
||||
|
||||
**Inputs:** `owner`, `name`, `path` (empty string = repo root), `ref` (optional branch/tag/SHA)
|
||||
**Output:** array of `{ name, path, type (file|dir|symlink), sha, size }`
|
||||
**Gitea endpoint:** `GET /api/v1/repos/{owner}/{repo}/contents/{path}`
|
||||
**Note:** same endpoint as `file_read` but returns an array when `path` is a directory. Client detects response shape (array vs object). If called on a file path, returns a descriptive error: `"path is a file, not a directory — use file_read"`.
|
||||
|
||||
---
|
||||
|
||||
### `file_delete`
|
||||
|
||||
**Inputs:** `owner`, `name`, `path`, `branch`, `message`, `sha` (required — current blob SHA)
|
||||
**Output:** `{ commit_sha, html_url }`
|
||||
**Gitea endpoint:** `DELETE /api/v1/repos/{owner}/{repo}/contents/{path}`
|
||||
|
||||
---
|
||||
|
||||
### `tag_create`
|
||||
|
||||
**Inputs:** `owner`, `name`, `tag` (tag name), `target` (branch name or commit SHA), `message` (optional — creates annotated tag if set)
|
||||
**Output:** `{ tag, commit_sha, html_url }`
|
||||
**Gitea endpoint:** `POST /api/v1/repos/{owner}/{repo}/tags`
|
||||
|
||||
---
|
||||
|
||||
## Gitea Client Methods
|
||||
|
||||
New methods on `gitea.Client`:
|
||||
|
||||
| Method | Endpoint | HTTP verb |
|
||||
|--------|----------|-----------|
|
||||
| `ListBranches(ctx, owner, repo, page, limit)` | `/api/v1/repos/{owner}/{repo}/branches` | GET |
|
||||
| `DeleteBranch(ctx, owner, repo, branch)` | `/api/v1/repos/{owner}/{repo}/branches/{branch}` | DELETE |
|
||||
| `GetBranchProtection(ctx, owner, repo, branch)` | `/api/v1/repos/{owner}/{repo}/branch_protections/{branch}` | GET |
|
||||
| `ListPullRequests(ctx, owner, repo, state, head, page, limit)` | `/api/v1/repos/{owner}/{repo}/pulls` | GET |
|
||||
| `MergePullRequest(ctx, owner, repo, index, args)` | `/api/v1/repos/{owner}/{repo}/pulls/{index}/merge` | POST |
|
||||
| `ListContents(ctx, owner, repo, path, ref)` | `/api/v1/repos/{owner}/{repo}/contents/{path}` | GET |
|
||||
| `DeleteFile(ctx, owner, repo, path, args)` | `/api/v1/repos/{owner}/{repo}/contents/{path}` | DELETE |
|
||||
| `CreateTag(ctx, owner, repo, args)` | `/api/v1/repos/{owner}/{repo}/tags` | POST |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
No structural changes. Each new tool is:
|
||||
- One file: `internal/tools/<tool_name>.go` + `internal/tools/<tool_name>_test.go`
|
||||
- One client method: `internal/gitea/<domain>.go` (added to existing domain files where logical)
|
||||
- Registered in `cmd/gitea-mcp/main.go`
|
||||
|
||||
`repo_status` is the only tool with internal composition — it calls three client methods and merges their results. It has no dedicated client method of its own.
|
||||
|
||||
New client methods go in existing domain files:
|
||||
- Branch methods → `internal/gitea/files.go` (already has `BranchExists`, `CreateBranch`)
|
||||
- PR methods → `internal/gitea/pulls.go`
|
||||
- Contents (dir_list, file_delete) → `internal/gitea/files.go`
|
||||
- Tags → new `internal/gitea/tags.go`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Pattern: table-driven tests with a `httptest.NewServer` mock, same as `file_write_branch_test.go`.
|
||||
|
||||
Each tool covers:
|
||||
- Happy path
|
||||
- 404 response
|
||||
- Allowlist rejection
|
||||
- Tool-specific edge cases:
|
||||
- `branch_delete`: 403 protected branch
|
||||
- `branch_protection_get`: 404 → `{protected: false}` not error
|
||||
- `dir_list`: file path → descriptive error
|
||||
- `pr_merge`: 405 checks failing, 409 merge conflict
|
||||
- `repo_status`: any one sub-call failing propagates the error
|
||||
|
||||
---
|
||||
|
||||
## Agent Decision Flow (Reference)
|
||||
|
||||
```
|
||||
1. repo_status(owner, name)
|
||||
→ if branch.protected && required_approvals > 0:
|
||||
use feature-branch workflow
|
||||
→ else:
|
||||
use trunk-based workflow
|
||||
|
||||
Feature-branch workflow:
|
||||
file_write_branch (auto-creates branch)
|
||||
→ pr_create
|
||||
→ [wait for CI via workflow_run_status]
|
||||
→ pr_merge
|
||||
→ branch_delete
|
||||
|
||||
Trunk-based workflow:
|
||||
file_write_branch(branch=main)
|
||||
→ [optionally] tag_create
|
||||
|
||||
Post-merge (either):
|
||||
→ [optionally] tag_create to trigger deployment
|
||||
```
|
||||
18
go.mod
18
go.mod
@@ -3,8 +3,24 @@ module gitea.d-ma.be/mathias/gitea-mcp
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/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
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -1,9 +1,41 @@
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||
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=
|
||||
|
||||
@@ -8,18 +8,26 @@ 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
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
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)
|
||||
|
||||
@@ -6,23 +6,41 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
hc *http.Client
|
||||
baseURL string
|
||||
token string
|
||||
hc *http.Client
|
||||
branchCache *expirable.LRU[string, string]
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
hc: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
hc: &http.Client{Timeout: 30 * time.Second},
|
||||
branchCache: expirable.NewLRU[string, string](64, nil, 60*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||
key := owner + "/" + name
|
||||
if v, ok := c.branchCache.Get(key); ok {
|
||||
return v, nil
|
||||
}
|
||||
repo, err := c.GetRepo(ctx, owner, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.branchCache.Add(key, repo.DefaultBranch)
|
||||
return repo.DefaultBranch, nil
|
||||
}
|
||||
|
||||
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
@@ -31,8 +49,9 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
token := c.token
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -43,11 +62,20 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||
b, status, err := c.doOnce(ctx, method, path, body)
|
||||
if err == nil && method == http.MethodGet && status >= 500 && status < 600 {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
return c.doOnce(ctx, method, path, body)
|
||||
}
|
||||
return b, status, err
|
||||
}
|
||||
|
||||
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
|
||||
return c.do(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
@@ -68,6 +96,10 @@ func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, erro
|
||||
return c.do(ctx, http.MethodDelete, path, nil)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -83,8 +115,9 @@ 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 token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -95,7 +128,7 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
@@ -27,3 +28,37 @@ func TestClientGetsTokenInHeader(t *testing.T) {
|
||||
assert.Contains(t, string(body), `"ok":true`)
|
||||
assert.Equal(t, "token test-token", gotAuth)
|
||||
}
|
||||
|
||||
func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n == 1 {
|
||||
http.Error(w, "boom", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
body, status, err := c.GetJSON(context.Background(), "/api/v1/test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, status)
|
||||
assert.Contains(t, string(body), `"ok":true`)
|
||||
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
http.Error(w, "boom", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, _, _ = c.PostJSON(context.Background(), "/api/v1/test", []byte(`{}`))
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "POST should not retry")
|
||||
}
|
||||
|
||||
43
internal/gitea/code_search.go
Normal file
43
internal/gitea/code_search.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type CodeSearchHit struct {
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
type codeSearchEnvelope struct {
|
||||
Data []CodeSearchHit `json:"data"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 30
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
|
||||
owner, repo, url.QueryEscape(q), page, limit)
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var env codeSearchEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return env.Data, nil
|
||||
}
|
||||
39
internal/gitea/code_search_test.go
Normal file
39
internal/gitea/code_search_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSearchCode(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{
|
||||
"path":"internal/gitea/code_search.go",
|
||||
"snippet":"func (c *Client) SearchCode",
|
||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
|
||||
"score":2.5
|
||||
}],
|
||||
"ok":true
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hits, 1)
|
||||
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
||||
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
|
||||
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
169
internal/gitea/issues.go
Normal file
169
internal/gitea/issues.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package gitea
|
||||
|
||||
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"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Labels []Label `json:"labels"`
|
||||
Assignees []User `json:"assignees"`
|
||||
Comments int `json:"comments"`
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type CreateIssueArgs struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []int64 `json:"labels,omitempty"`
|
||||
Assignees []string `json:"assignees,omitempty"`
|
||||
Milestone int64 `json:"milestone,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type IssueComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
||||
// works for both issues and pull requests (PRs share index space with issues).
|
||||
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, index int, body string) (*IssueComment, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
|
||||
payload, err := json.Marshal(map[string]string{"body": body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, respBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c2 IssueComment
|
||||
if err := json.Unmarshal(respBody, &c2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c2, nil
|
||||
}
|
||||
103
internal/gitea/issues_test.go
Normal file
103
internal/gitea/issues_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateIssue(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"x","body":"y","html_url":"http://example.com/issues/42","state":"open"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
iss, err := c.CreateIssue(context.Background(), "o", "r", gitea.CreateIssueArgs{
|
||||
Title: "x",
|
||||
Body: "y",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "x", payload["title"])
|
||||
assert.Equal(t, "y", payload["body"])
|
||||
|
||||
assert.Equal(t, 42, iss.Number)
|
||||
assert.Equal(t, "x", iss.Title)
|
||||
assert.Equal(t, "y", iss.Body)
|
||||
assert.Equal(t, "http://example.com/issues/42", iss.HTMLURL)
|
||||
assert.Equal(t, "open", iss.State)
|
||||
}
|
||||
|
||||
func 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 TestCreateIssueComment(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
comment, err := c.CreateIssueComment(context.Background(), "o", "r", 42, "hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "hello", payload["body"])
|
||||
|
||||
assert.Equal(t, int64(7), comment.ID)
|
||||
assert.Equal(t, "hello", comment.Body)
|
||||
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
||||
}
|
||||
71
internal/gitea/mirrors.go
Normal file
71
internal/gitea/mirrors.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type PushMirror struct {
|
||||
ID int `json:"id"`
|
||||
RemoteName string `json:"remote_name"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
}
|
||||
|
||||
type AddPushMirrorArgs struct {
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
RemoteUsername string `json:"remote_username,omitempty"`
|
||||
RemotePassword string `json:"remote_password,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
SyncOnCommit bool `json:"sync_on_commit,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m PushMirror
|
||||
if err := json.Unmarshal(resp, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||
resp, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mirrors []PushMirror
|
||||
if err := json.Unmarshal(resp, &mirrors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mirrors, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName)
|
||||
resp, status, err := c.DeleteJSON(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == 204 {
|
||||
return nil
|
||||
}
|
||||
return MapStatus(status, resp)
|
||||
}
|
||||
64
internal/gitea/mirrors_test.go
Normal file
64
internal/gitea/mirrors_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddPushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
|
||||
RemoteAddress: "https://github.com/mathias/infra.git",
|
||||
RemoteUsername: "mathias",
|
||||
RemotePassword: "secret",
|
||||
Interval: "8h0m0s",
|
||||
SyncOnCommit: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mirror-github", m.RemoteName)
|
||||
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
|
||||
}
|
||||
|
||||
func TestListPushMirrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mirrors, 1)
|
||||
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
|
||||
}
|
||||
|
||||
func TestDeletePushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type PullRequest struct {
|
||||
@@ -64,3 +65,85 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, index i
|
||||
}
|
||||
return &pr, nil
|
||||
}
|
||||
|
||||
type PullRequestFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"` // added | modified | deleted | renamed
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
}
|
||||
|
||||
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, index int) ([]PullRequestFile, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", owner, repo, index)
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []PullRequestFile
|
||||
if err := json.Unmarshal(body, &files); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetPullRequestDiff returns the raw unified diff. The endpoint serves text/plain, not JSON,
|
||||
// so we use doRaw to bypass the json Accept header expectation.
|
||||
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, index int) ([]byte, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d.diff", owner, repo, index)
|
||||
resp, err := c.doRaw(ctx, "GET", p, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(resp.Status, resp.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
type MergePRArgs struct {
|
||||
Do string `json:"Do"`
|
||||
Title string `json:"merge_message_title,omitempty"`
|
||||
Body string `json:"merge_message_field,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, index int, args MergePRArgs) error {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return MapStatus(status, body)
|
||||
}
|
||||
|
||||
func (c *Client) ListPullRequests(ctx context.Context, owner, repo, state, head string, page, limit int) ([]PullRequest, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 30
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=%s&page=%d&limit=%d",
|
||||
owner, repo, url.QueryEscape(state), page, limit)
|
||||
if head != "" {
|
||||
p += "&head=" + url.QueryEscape(head)
|
||||
}
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prs []PullRequest
|
||||
if err := json.Unmarshal(body, &prs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
@@ -93,3 +93,98 @@ func TestGetPullRequest(t *testing.T) {
|
||||
assert.Equal(t, "open", pr.State)
|
||||
assert.True(t, pr.Draft)
|
||||
}
|
||||
|
||||
func TestGetPullRequestFiles(t *testing.T) {
|
||||
filesJSON := `[
|
||||
{"filename":"main.go","status":"modified","additions":10,"deletions":5},
|
||||
{"filename":"README.md","status":"added","additions":20,"deletions":0}
|
||||
]`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/pulls/42/files", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(filesJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
files, err := c.GetPullRequestFiles(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
assert.Equal(t, "main.go", files[0].Filename)
|
||||
assert.Equal(t, "modified", files[0].Status)
|
||||
assert.Equal(t, 10, files[0].Additions)
|
||||
assert.Equal(t, 5, files[0].Deletions)
|
||||
assert.Equal(t, "README.md", files[1].Filename)
|
||||
assert.Equal(t, "added", files[1].Status)
|
||||
assert.Equal(t, 20, files[1].Additions)
|
||||
assert.Equal(t, 0, files[1].Deletions)
|
||||
}
|
||||
|
||||
func TestGetPullRequestDiff(t *testing.T) {
|
||||
rawDiff := "diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1,2 +1,3 @@\n+package main\n"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/pulls/42.diff", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(rawDiff))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
diff, err := c.GetPullRequestDiff(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte(rawDiff), diff)
|
||||
}
|
||||
|
||||
func TestListPullRequests(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path)
|
||||
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
||||
assert.Equal(t, "feat/x", r.URL.Query().Get("head"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[` + pullFixture + `]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
prs, err := c.ListPullRequests(context.Background(), "o", "r", "open", "feat/x", 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prs, 1)
|
||||
assert.Equal(t, 7, prs[0].Number)
|
||||
assert.Equal(t, "feat/x", prs[0].Head.Ref)
|
||||
}
|
||||
|
||||
func TestMergePullRequestSuccess(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/pulls/7/merge", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "squash"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "squash", payload["Do"])
|
||||
}
|
||||
|
||||
func TestMergePullRequestConflict(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "merge"})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
@@ -14,6 +15,110 @@ type Repo struct {
|
||||
Private bool `json:"private"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Template bool `json:"template"`
|
||||
}
|
||||
|
||||
type TreeEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "blob" or "tree"
|
||||
SHA string `json:"sha"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Tree struct {
|
||||
SHA string `json:"sha"`
|
||||
URL string `json:"url"`
|
||||
Tree []TreeEntry `json:"tree"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
|
||||
if recursive {
|
||||
path += "?recursive=1"
|
||||
}
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t Tree
|
||||
if err := json.Unmarshal(body, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -38,6 +143,103 @@ func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) (
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
type repoSearchEnvelope struct {
|
||||
Data []Repo `json:"data"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit int) ([]Repo, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 30
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/search?q=%s&page=%d&limit=%d",
|
||||
url.QueryEscape(q), page, limit)
|
||||
if owner != "" {
|
||||
path += "&owner=" + url.QueryEscape(owner)
|
||||
}
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var env repoSearchEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return env.Data, nil
|
||||
}
|
||||
|
||||
type CreateRepoArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
AutoInit bool `json:"auto_init,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
// Org, when non-empty, creates the repo under the named organisation.
|
||||
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
||||
Org string `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
||||
var path string
|
||||
if args.Org != "" {
|
||||
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
||||
} else {
|
||||
path = "/api/v1/user/repos"
|
||||
}
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
||||
type UpdateRepoArgs struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||
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)
|
||||
@@ -53,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
@@ -11,6 +12,23 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSearchRepos(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
|
||||
assert.Equal(t, "infra", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
repos, err := c.SearchRepos(context.Background(), "infra", "mathias", 1, 30)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repos, 1)
|
||||
assert.Equal(t, "mathias/infra", repos[0].FullName)
|
||||
}
|
||||
|
||||
func TestListRepos(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path)
|
||||
@@ -28,3 +46,146 @@ func TestListRepos(t *testing.T) {
|
||||
assert.Equal(t, "mathias/infra", repos[0].FullName)
|
||||
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) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"o/infra","default_branch":"trunk"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
b, err := c.DefaultBranch(context.Background(), "o", "infra")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "trunk", b)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "5 calls should cause exactly 1 server hit due to cache")
|
||||
}
|
||||
|
||||
42
internal/gitea/tags.go
Normal file
42
internal/gitea/tags.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type CreateTagArgs struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Commit struct {
|
||||
Sha string `json:"sha"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateTag(ctx context.Context, owner, repo string, args CreateTagArgs) (*Tag, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/tags", owner, repo)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tag Tag
|
||||
if err := json.Unmarshal(body, &tag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag, nil
|
||||
}
|
||||
49
internal/gitea/tags_test.go
Normal file
49
internal/gitea/tags_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateTag(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/tags", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{
|
||||
"name":"v1.0.0",
|
||||
"id":"tagsha",
|
||||
"message":"release",
|
||||
"commit":{"sha":"cmt1","url":"http://example.com/commit/cmt1"}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
tag, err := c.CreateTag(context.Background(), "o", "r", gitea.CreateTagArgs{
|
||||
TagName: "v1.0.0",
|
||||
Target: "main",
|
||||
Message: "release",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1.0.0", tag.Name)
|
||||
assert.Equal(t, "cmt1", tag.Commit.Sha)
|
||||
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "v1.0.0", payload["tag_name"])
|
||||
assert.Equal(t, "main", payload["target"])
|
||||
assert.Equal(t, "release", payload["message"])
|
||||
}
|
||||
72
internal/gitea/templates.go
Normal file
72
internal/gitea/templates.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate.
|
||||
type GenerateFromTemplateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
GitContent bool `json:"git_content"` // include all template files
|
||||
}
|
||||
|
||||
// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate.
|
||||
func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName)
|
||||
payload, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PostJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SubstituteFile reads a file from the given branch, applies string replacements,
|
||||
// and writes it back if any changes were made. Best-effort — returns a wrapped error
|
||||
// that includes the file path.
|
||||
func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error {
|
||||
fc, err := c.GetFileContents(ctx, owner, repo, path, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
content := string(decoded)
|
||||
for k, v := range replacements {
|
||||
content = strings.ReplaceAll(content, k, v)
|
||||
}
|
||||
if content == string(decoded) {
|
||||
return nil // no changes, skip write
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
_, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{
|
||||
Branch: branch,
|
||||
Content: encoded,
|
||||
Message: "Apply template substitutions",
|
||||
Sha: fc.Sha,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
156
internal/gitea/templates_test.go
Normal file
156
internal/gitea/templates_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateFromTemplate(t *testing.T) {
|
||||
var capturedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
var err error
|
||||
capturedBody, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"name":"new-svc",
|
||||
"full_name":"mathias/new-svc",
|
||||
"default_branch":"main",
|
||||
"description":"A new service",
|
||||
"private":true,
|
||||
"clone_url":"http://gitea.example.com/mathias/new-svc.git",
|
||||
"html_url":"http://gitea.example.com/mathias/new-svc",
|
||||
"template":false
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{
|
||||
Owner: "mathias",
|
||||
Name: "new-svc",
|
||||
Description: "A new service",
|
||||
Private: true,
|
||||
GitContent: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the captured POST body contains the expected fields.
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(capturedBody, &payload))
|
||||
assert.Equal(t, "mathias", payload["owner"])
|
||||
assert.Equal(t, "new-svc", payload["name"])
|
||||
assert.Equal(t, "A new service", payload["description"])
|
||||
assert.Equal(t, true, payload["private"])
|
||||
assert.Equal(t, true, payload["git_content"])
|
||||
|
||||
// Verify the decoded repo fields.
|
||||
assert.Equal(t, "new-svc", repo.Name)
|
||||
assert.Equal(t, "mathias/new-svc", repo.FullName)
|
||||
assert.Equal(t, "main", repo.DefaultBranch)
|
||||
assert.Equal(t, "A new service", repo.Description)
|
||||
assert.True(t, repo.Private)
|
||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL)
|
||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL)
|
||||
}
|
||||
|
||||
func TestSubstituteFileApplies(t *testing.T) {
|
||||
originalContent := "module __MODULE_PATH__\n\ngo 1.22\n"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||
|
||||
var capturedPutBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||
assert.Equal(t, "main", r.URL.Query().Get("ref"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`))
|
||||
case http.MethodPut:
|
||||
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||
var err error
|
||||
capturedPutBody, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`))
|
||||
default:
|
||||
t.Errorf("unexpected method %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the PUT body contains the substituted content.
|
||||
require.NotNil(t, capturedPutBody, "PUT should have been called")
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.Unmarshal(capturedPutBody, &payload))
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload["content"])
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc")
|
||||
assert.NotContains(t, string(decoded), "__MODULE_PATH__")
|
||||
assert.Equal(t, "abc123", payload["sha"])
|
||||
assert.Equal(t, "Apply template substitutions", payload["message"])
|
||||
}
|
||||
|
||||
func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) {
|
||||
originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||
|
||||
var putCount atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`))
|
||||
case http.MethodPut:
|
||||
putCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
// Replacements that don't match anything in the content.
|
||||
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged")
|
||||
}
|
||||
|
||||
func TestSubstituteFileReadError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"file not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -54,11 +55,21 @@ func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow str
|
||||
|
||||
// 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 +88,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,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
64
internal/tools/branch_delete.go
Normal file
64
internal/tools/branch_delete.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type BranchDelete struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewBranchDelete(c *gitea.Client, a *allowlist.Allowlist) *BranchDelete {
|
||||
return &BranchDelete{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "branch_delete",
|
||||
Description: "Delete a branch from a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"branch":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","branch"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args branchDeleteArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Branch == "" {
|
||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"deleted": true,
|
||||
"branch": args.Branch,
|
||||
})
|
||||
}
|
||||
51
internal/tools/branch_delete_test.go
Normal file
51
internal/tools/branch_delete_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBranchDeleteSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, true, result["deleted"])
|
||||
assert.Equal(t, "feat/x", result["branch"])
|
||||
}
|
||||
|
||||
func TestBranchDeleteProtectedReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"message":"branch is protected"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewBranchDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func TestBranchDeleteAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewBranchDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"feat/x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
67
internal/tools/branch_list.go
Normal file
67
internal/tools/branch_list.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type BranchList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewBranchList(c *gitea.Client, a *allowlist.Allowlist) *BranchList {
|
||||
return &BranchList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *BranchList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "branch_list",
|
||||
Description: "List branches in a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args branchListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(branches))
|
||||
for i, b := range branches {
|
||||
result[i] = map[string]any{
|
||||
"name": b.Name,
|
||||
"sha": b.Commit.ID,
|
||||
}
|
||||
}
|
||||
return textOK(result)
|
||||
}
|
||||
43
internal/tools/branch_list_test.go
Normal file
43
internal/tools/branch_list_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBranchListReturnsNames(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"main","commit":{"id":"abc","url":""}},
|
||||
{"name":"feat/x","commit":{"id":"def","url":""}}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewBranchList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "main", result[0]["name"])
|
||||
assert.Equal(t, "abc", result[0]["sha"])
|
||||
assert.Equal(t, "feat/x", result[1]["name"])
|
||||
}
|
||||
|
||||
func TestBranchListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewBranchList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
63
internal/tools/branch_protection_get.go
Normal file
63
internal/tools/branch_protection_get.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type BranchProtectionGet struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewBranchProtectionGet(c *gitea.Client, a *allowlist.Allowlist) *BranchProtectionGet {
|
||||
return &BranchProtectionGet{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "branch_protection_get",
|
||||
Description: "Get branch protection rules. Returns {protected:false} if no rule exists — never returns an error for unprotected branches.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"branch":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","branch"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type branchProtectionGetArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args branchProtectionGetArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"protected": bp.Protected,
|
||||
"required_approvals": bp.RequiredApprovals,
|
||||
"push_whitelist": bp.PushWhitelist,
|
||||
"merge_whitelist": bp.MergeWhitelist,
|
||||
})
|
||||
}
|
||||
54
internal/tools/branch_protection_get_test.go
Normal file
54
internal/tools/branch_protection_get_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBranchProtectionGetProtected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, true, result["protected"])
|
||||
assert.Equal(t, float64(1), result["required_approvals"])
|
||||
}
|
||||
|
||||
func TestBranchProtectionGetUnprotected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewBranchProtectionGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"feat/x"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, false, result["protected"])
|
||||
}
|
||||
|
||||
func TestBranchProtectionGetAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewBranchProtectionGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
189
internal/tools/code_search.go
Normal file
189
internal/tools/code_search.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type semaphore chan struct{}
|
||||
|
||||
func newSem(n int) semaphore { return make(semaphore, n) }
|
||||
func (s semaphore) acquire() { s <- struct{}{} }
|
||||
func (s semaphore) release() { <-s }
|
||||
|
||||
type CodeSearch struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewCodeSearch(c *gitea.Client, a *allowlist.Allowlist) *CodeSearch {
|
||||
return &CodeSearch{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "code_search",
|
||||
Description: "Search code across one repo or fan out across an owner's repos.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"q":{"type":"string"},
|
||||
"owner":{"type":"string"},
|
||||
"repo":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["q","owner"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type codeSearchArgs struct {
|
||||
Q string `json:"q"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type codeSearchResult struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args codeSearchArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Q == "" {
|
||||
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
|
||||
if args.Repo != "" {
|
||||
return t.singleRepo(ctx, args)
|
||||
}
|
||||
return t.fanOut(ctx, args)
|
||||
}
|
||||
|
||||
func (t *CodeSearch) singleRepo(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||
hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]codeSearchResult, 0, len(hits))
|
||||
repoFull := args.Owner + "/" + args.Repo
|
||||
for _, h := range hits {
|
||||
score := h.Score
|
||||
if score == 0 {
|
||||
score = 1.0
|
||||
}
|
||||
results = append(results, codeSearchResult{
|
||||
Repo: repoFull,
|
||||
Path: h.Path,
|
||||
Snippet: h.Snippet,
|
||||
Score: score,
|
||||
HTMLURL: h.HTMLURL,
|
||||
})
|
||||
}
|
||||
out := map[string]any{"results": results}
|
||||
if len(hits) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
|
||||
func (t *CodeSearch) fanOut(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||
repos, err := t.c.ListRepos(ctx, args.Owner, 1, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type repoResult struct {
|
||||
repo string
|
||||
hits []gitea.CodeSearchHit
|
||||
err error
|
||||
}
|
||||
resultsCh := make(chan repoResult, len(repos))
|
||||
sem := newSem(5)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, r := range repos {
|
||||
repo := r // capture
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem.acquire()
|
||||
defer sem.release()
|
||||
|
||||
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hits, err := t.c.SearchCode(rctx, args.Owner, repo.Name, args.Q, 1, args.Limit)
|
||||
resultsCh <- repoResult{repo: args.Owner + "/" + repo.Name, hits: hits, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(resultsCh)
|
||||
|
||||
merged := make([]codeSearchResult, 0)
|
||||
var partialRepos []string
|
||||
for rr := range resultsCh {
|
||||
if rr.err != nil {
|
||||
partialRepos = append(partialRepos, rr.repo)
|
||||
continue
|
||||
}
|
||||
for _, h := range rr.hits {
|
||||
score := h.Score
|
||||
if score == 0 {
|
||||
score = 1.0
|
||||
}
|
||||
merged = append(merged, codeSearchResult{
|
||||
Repo: rr.repo, Path: h.Path, Snippet: h.Snippet, Score: score, HTMLURL: h.HTMLURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score desc, then by repo+path for determinism.
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
if merged[i].Score != merged[j].Score {
|
||||
return merged[i].Score > merged[j].Score
|
||||
}
|
||||
if merged[i].Repo != merged[j].Repo {
|
||||
return merged[i].Repo < merged[j].Repo
|
||||
}
|
||||
return merged[i].Path < merged[j].Path
|
||||
})
|
||||
if len(merged) > args.Limit {
|
||||
merged = merged[:args.Limit]
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"results": merged,
|
||||
"partial": len(partialRepos) > 0,
|
||||
}
|
||||
if len(partialRepos) > 0 {
|
||||
sort.Strings(partialRepos)
|
||||
out["partial_repos"] = partialRepos
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
187
internal/tools/code_search_test.go
Normal file
187
internal/tools/code_search_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
||||
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"data":[{
|
||||
"path":"internal/gitea/repos.go",
|
||||
"snippet":"func (c *Client) ListRepos",
|
||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
||||
"score":3.0
|
||||
}],
|
||||
"ok":true
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result.Results, 1)
|
||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
||||
}
|
||||
|
||||
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCodeSearchRequiresQ(t *testing.T) {
|
||||
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
} `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.False(t, result.Partial)
|
||||
require.Len(t, result.Results, 2)
|
||||
|
||||
repos := make([]string, 0, 2)
|
||||
for _, r := range result.Results {
|
||||
repos = append(repos, r.Repo)
|
||||
}
|
||||
assert.Contains(t, repos, "mathias/infra")
|
||||
assert.Contains(t, repos, "mathias/gitea-mcp")
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
||||
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/infra/search":
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/broken/search":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
||||
Partial bool `json:"partial"`
|
||||
PartialRepos []string `json:"partial_repos"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.True(t, result.Partial)
|
||||
require.Len(t, result.PartialRepos, 1)
|
||||
assert.Equal(t, "mathias/broken", result.PartialRepos[0])
|
||||
require.Len(t, result.Results, 1)
|
||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||
}
|
||||
|
||||
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/users/mathias/repos":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
||||
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
||||
]`))
|
||||
case "/api/v1/repos/mathias/alpha/search":
|
||||
// low score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
||||
case "/api/v1/repos/mathias/beta/search":
|
||||
// high score
|
||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result.Results, 2)
|
||||
// First result must be the high-score one
|
||||
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||
"expected results sorted by score desc, got %v then %v",
|
||||
result.Results[0].Score, result.Results[1].Score)
|
||||
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
||||
}
|
||||
155
internal/tools/create_project_from_template.go
Normal file
155
internal/tools/create_project_from_template.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||
|
||||
var substitutionFiles = []string{
|
||||
"go.mod",
|
||||
"Taskfile.yml",
|
||||
"Dockerfile",
|
||||
".gitea/workflows/cd.yml",
|
||||
"README.md",
|
||||
".context/PROJECT.md",
|
||||
}
|
||||
|
||||
func substitutions(owner, name string) map[string]string {
|
||||
return map[string]string{
|
||||
"__PROJECT_NAME__": name,
|
||||
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||
type CreateProjectFromTemplate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
templateOwner string
|
||||
templateName string
|
||||
}
|
||||
|
||||
func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate {
|
||||
return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName}
|
||||
}
|
||||
|
||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "create_project_from_template",
|
||||
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean"},
|
||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type createProjectArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
TemplateName string `json:"template_name"`
|
||||
}
|
||||
|
||||
type createProjectResult struct {
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
}
|
||||
|
||||
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args createProjectArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Allowlist check first.
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate name format.
|
||||
if !nameRe.MatchString(args.Name) {
|
||||
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// 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 nil, fmt.Errorf("template lookup: %w", err)
|
||||
}
|
||||
if !tmpl.Template {
|
||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, 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)
|
||||
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||
return nil, fmt.Errorf("destination check: %w", err)
|
||||
}
|
||||
|
||||
// Generate repo from template.
|
||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
||||
Owner: args.Owner,
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
GitContent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate: %w", err)
|
||||
}
|
||||
|
||||
result := createProjectResult{
|
||||
FullName: newRepo.FullName,
|
||||
HTMLURL: newRepo.HTMLURL,
|
||||
CloneURL: newRepo.CloneURL,
|
||||
DefaultBranch: newRepo.DefaultBranch,
|
||||
}
|
||||
|
||||
// Substitute placeholders in known files (best-effort).
|
||||
repls := substitutions(args.Owner, args.Name)
|
||||
branch := newRepo.DefaultBranch
|
||||
for _, path := range substitutionFiles {
|
||||
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
||||
// Files that don't exist in this template are silently skipped.
|
||||
if errors.Is(err, gitea.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
// Any other error halts the substitution pass with partial_failure recorded.
|
||||
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
||||
break
|
||||
}
|
||||
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
||||
}
|
||||
|
||||
return textOK(result)
|
||||
}
|
||||
322
internal/tools/create_project_from_template_test.go
Normal file
322
internal/tools/create_project_from_template_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
||||
var substitutionFileList = []string{
|
||||
"go.mod",
|
||||
"Taskfile.yml",
|
||||
"Dockerfile",
|
||||
".gitea/workflows/cd.yml",
|
||||
"README.md",
|
||||
".context/PROJECT.md",
|
||||
}
|
||||
|
||||
// contentWithPlaceholder is a template file body that contains the placeholder.
|
||||
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
||||
|
||||
func encodedContent(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// fileContentsJSON returns a JSON FileContents object for the given path.
|
||||
func fileContentsJSON(path string) string {
|
||||
enc := encodedContent(contentWithPlaceholder)
|
||||
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
||||
path, strings.ReplaceAll(path, "/", "-"), enc)
|
||||
}
|
||||
|
||||
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
||||
func fileWriteResultJSON(path string) string {
|
||||
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
||||
}
|
||||
|
||||
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
||||
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||
name, name, name, name, isTemplate)
|
||||
}
|
||||
|
||||
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
||||
func newGeneratedRepoJSON(name string) string {
|
||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||
name, name, name, name)
|
||||
}
|
||||
|
||||
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
||||
c := gitea.NewClient(srvURL, "tok")
|
||||
a := allowlist.New([]string{"mathias"})
|
||||
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
||||
}
|
||||
|
||||
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
||||
func TestCreateProjectHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
// Template repo lookup
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
|
||||
// Destination repo lookup — 404 means it doesn't exist yet
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
// Generate
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||
|
||||
// File contents GET — handle all 6 substitution files
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
// File contents PUT — handle all 6 substitution files
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
FilesSubstituted []string `json:"files_substituted"`
|
||||
PartialFailure string `json:"partial_failure,omitempty"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
assert.Equal(t, "mathias/new-svc", out.FullName)
|
||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
||||
assert.Equal(t, "main", out.DefaultBranch)
|
||||
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
}
|
||||
|
||||
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
||||
// server-configured default, so the same binary can generate from template-go-web or
|
||||
// template-go-agent without restart.
|
||||
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
||||
var templateLookups, generateCalls []string
|
||||
|
||||
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-agent":
|
||||
templateLookups = append(templateLookups, "template-go-agent")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
templateLookups = append(templateLookups, "template-go-web")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
||||
generateCalls = append(generateCalls, r.URL.Path)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
||||
"override must direct the template lookup, not the server default")
|
||||
require.Len(t, generateCalls, 1)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
||||
"override must direct the /generate call too")
|
||||
}
|
||||
|
||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||
tool := tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
"mathias", "template-go-web",
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
||||
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewCreateProjectFromTemplate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
"mathias", "template-go-web",
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "allowlist")
|
||||
}
|
||||
|
||||
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
||||
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Template lookup returns a non-template repo.
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestCreateProjectDestinationExists: destination repo already exists.
|
||||
func TestCreateProjectDestinationExists(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||
// Destination exists — return 200.
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||
require.Error(t, err)
|
||||
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")
|
||||
}
|
||||
70
internal/tools/dir_list.go
Normal file
70
internal/tools/dir_list.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type DirList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewDirList(c *gitea.Client, a *allowlist.Allowlist) *DirList {
|
||||
return &DirList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *DirList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "dir_list",
|
||||
Description: "List directory contents in a repository. Use empty path for repo root. Returns name, path, type (file/dir/symlink), sha, size per entry.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"ref":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type dirListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args dirListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = map[string]any{
|
||||
"name": e.Name,
|
||||
"path": e.Path,
|
||||
"type": e.Type,
|
||||
"sha": e.Sha,
|
||||
"size": e.Size,
|
||||
}
|
||||
}
|
||||
return textOK(result)
|
||||
}
|
||||
75
internal/tools/dir_list_test.go
Normal file
75
internal/tools/dir_list_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDirListReturnsEntries(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/owner/repo/contents/src", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":512},
|
||||
{"name":"util","path":"src/util","type":"dir","sha":"def","size":0}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"src"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "main.go", result[0]["name"])
|
||||
assert.Equal(t, "file", result[0]["type"])
|
||||
assert.Equal(t, "util", result[1]["name"])
|
||||
assert.Equal(t, "dir", result[1]["type"])
|
||||
}
|
||||
|
||||
func TestDirListRootPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/owner/repo/contents/", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":""}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestDirListOnFileReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"path":"README.md","sha":"abc","size":10,"content":"","encoding":"base64"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewDirList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","path":"README.md"}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
func TestDirListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewDirList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","path":""}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
78
internal/tools/file_delete.go
Normal file
78
internal/tools/file_delete.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type FileDelete struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewFileDelete(c *gitea.Client, a *allowlist.Allowlist) *FileDelete {
|
||||
return &FileDelete{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *FileDelete) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "file_delete",
|
||||
Description: "Delete a file from a repository branch. sha is the current blob SHA (from file_read).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"path":{"type":"string"},
|
||||
"branch":{"type":"string"},
|
||||
"message":{"type":"string"},
|
||||
"sha":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","path","branch","message","sha"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type fileDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Branch string `json:"branch"`
|
||||
Message string `json:"message"`
|
||||
Sha string `json:"sha"`
|
||||
}
|
||||
|
||||
func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args fileDeleteArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Sha == "" {
|
||||
return nil, fmt.Errorf("sha is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Message == "" {
|
||||
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
|
||||
Branch: args.Branch,
|
||||
Message: args.Message,
|
||||
Sha: args.Sha,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"commit_sha": result.Commit.Sha,
|
||||
"html_url": result.Commit.HTMLURL,
|
||||
})
|
||||
}
|
||||
52
internal/tools/file_delete_test.go
Normal file
52
internal/tools/file_delete_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFileDeleteSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewFileDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||
"owner":"owner","name":"repo","path":"src/old.go",
|
||||
"branch":"main","message":"remove old.go","sha":"blobsha"
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, "cmt1", result["commit_sha"])
|
||||
}
|
||||
|
||||
func TestFileDeleteRequiresSha(t *testing.T) {
|
||||
tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||
"owner":"owner","name":"repo","path":"f.go","branch":"main","message":"rm"
|
||||
}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
func TestFileDeleteAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewFileDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||
"owner":"evil","name":"repo","path":"f.go","branch":"main","message":"rm","sha":"abc"
|
||||
}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -57,11 +57,11 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
||||
|
||||
ref := args.Ref
|
||||
if ref == "" {
|
||||
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name)
|
||||
var err error
|
||||
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref = repo.DefaultBranch
|
||||
}
|
||||
|
||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
||||
|
||||
@@ -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"}`))
|
||||
|
||||
@@ -75,11 +75,11 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
||||
if !exists {
|
||||
base := args.Base
|
||||
if base == "" {
|
||||
repo, err := t.c.GetRepo(ctx, args.Owner, args.Name)
|
||||
var err error
|
||||
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base = repo.DefaultBranch
|
||||
}
|
||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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()
|
||||
|
||||
56
internal/tools/issue_close.go
Normal file
56
internal/tools/issue_close.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCloseArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueCloseArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "closed")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
52
internal/tools/issue_close_test.go
Normal file
52
internal/tools/issue_close_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
73
internal/tools/issue_comment.go
Normal file
73
internal/tools/issue_comment.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueComment struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueComment(c *gitea.Client, a *allowlist.Allowlist) *IssueComment {
|
||||
return &IssueComment{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueComment) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_comment",
|
||||
Description: "Comment on an issue. Applies identity footer to body.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1},
|
||||
"body":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","number","body"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCommentArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueCommentArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Body == "" {
|
||||
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||
|
||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"id": c.ID,
|
||||
"html_url": c.HTMLURL,
|
||||
})
|
||||
}
|
||||
54
internal/tools/issue_comment_test.go
Normal file
54
internal/tools/issue_comment_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const commentFixture = `{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`
|
||||
|
||||
func TestIssueCommentAppliesFooter(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(commentFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
ctx := callerContext("mathiasbq")
|
||||
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":42,"body":"hello"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
body, _ := payload["body"].(string)
|
||||
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||
}
|
||||
|
||||
func TestIssueCommentAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueCommentRequiresBody(t *testing.T) {
|
||||
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
84
internal/tools/issue_create.go
Normal file
84
internal/tools/issue_create.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueCreate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueCreate(c *gitea.Client, a *allowlist.Allowlist) *IssueCreate {
|
||||
return &IssueCreate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_create",
|
||||
Description: "Create an issue. Applies identity footer to body.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"title":{"type":"string"},
|
||||
"body":{"type":"string"},
|
||||
"labels":{"type":"array","items":{"type":"integer"}},
|
||||
"assignees":{"type":"array","items":{"type":"string"}},
|
||||
"milestone":{"type":"integer"}
|
||||
},
|
||||
"required":["owner","name","title"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []int64 `json:"labels"`
|
||||
Assignees []string `json:"assignees"`
|
||||
Milestone int64 `json:"milestone"`
|
||||
}
|
||||
|
||||
func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueCreateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Title == "" {
|
||||
return nil, fmt.Errorf("title is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||
|
||||
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
|
||||
Title: args.Title,
|
||||
Body: body,
|
||||
Labels: args.Labels,
|
||||
Assignees: args.Assignees,
|
||||
Milestone: args.Milestone,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"number": iss.Number,
|
||||
"title": iss.Title,
|
||||
"html_url": iss.HTMLURL,
|
||||
"state": iss.State,
|
||||
})
|
||||
}
|
||||
81
internal/tools/issue_create_test.go
Normal file
81
internal/tools/issue_create_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const issueFixture = `{
|
||||
"number": 42,
|
||||
"title": "x",
|
||||
"body": "y",
|
||||
"html_url": "http://example.com/issues/42",
|
||||
"state": "open"
|
||||
}`
|
||||
|
||||
func TestIssueCreateAppliesIdentityFooter(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(issueFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
ctx := callerContext("mathiasbq")
|
||||
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
body, _ := payload["body"].(string)
|
||||
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||
}
|
||||
|
||||
func TestIssueCreateNoFooterWhenCallerEmpty(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(issueFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
body, _ := payload["body"].(string)
|
||||
assert.NotContains(t, body, "_Created via git-mcp on behalf of")
|
||||
}
|
||||
|
||||
func TestIssueCreateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","title":"T"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueCreateRequiresTitle(t *testing.T) {
|
||||
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":""}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
54
internal/tools/issue_get.go
Normal file
54
internal/tools/issue_get.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueGetArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueGetArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
50
internal/tools/issue_get_test.go
Normal file
50
internal/tools/issue_get_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueGetTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":42`)
|
||||
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||
assert.Contains(t, string(out), `"comments":3`)
|
||||
}
|
||||
|
||||
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
83
internal/tools/issue_list.go
Normal file
83
internal/tools/issue_list.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"name":{"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","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Labels string `json:"labels"`
|
||||
Since string `json:"since"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.State == "" {
|
||||
args.State = "open"
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, 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)
|
||||
}
|
||||
88
internal/tools/issue_list_test.go
Normal file
88
internal/tools/issue_list_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
|
||||
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"number":42`)
|
||||
assert.Contains(t, out, `"number":41`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state filter",
|
||||
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "closed"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "label + since filter",
|
||||
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
|
||||
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "empty result",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"state": "open"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
assert.NotContains(t, out, `next_page`)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||
require.NoError(t, err)
|
||||
tc.assert(t, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
56
internal/tools/issue_reopen.go
Normal file
56
internal/tools/issue_reopen.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type 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"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueReopenArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueReopenArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "open")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
40
internal/tools/issue_reopen_test.go
Normal file
40
internal/tools/issue_reopen_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
73
internal/tools/pr_comment.go
Normal file
73
internal/tools/pr_comment.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type PRComment struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewPRComment(c *gitea.Client, a *allowlist.Allowlist) *PRComment {
|
||||
return &PRComment{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *PRComment) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "pr_comment",
|
||||
Description: "Comment on a pull request (conversation, not inline review). Applies identity footer.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1},
|
||||
"body":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","number","body"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type prCommentArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args prCommentArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Body == "" {
|
||||
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||
|
||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"id": c.ID,
|
||||
"html_url": c.HTMLURL,
|
||||
})
|
||||
}
|
||||
53
internal/tools/pr_comment_test.go
Normal file
53
internal/tools/pr_comment_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPRCommentAppliesFooter(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// PRs share index space with issues — same endpoint
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/3/comments", r.URL.Path)
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(commentFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
ctx := callerContext("mathiasbq")
|
||||
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":3,"body":"looks good"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
body, _ := payload["body"].(string)
|
||||
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
|
||||
}
|
||||
|
||||
func TestPRCommentAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPRCommentRequiresBody(t *testing.T) {
|
||||
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
177
internal/tools/pr_files_diff.go
Normal file
177
internal/tools/pr_files_diff.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFileDiffBytes = 20 * 1024
|
||||
maxResponseBytes = 200 * 1024
|
||||
)
|
||||
|
||||
type PRFilesDiff struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewPRFilesDiff(c *gitea.Client, a *allowlist.Allowlist) *PRFilesDiff {
|
||||
return &PRFilesDiff{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "pr_files_diff",
|
||||
Description: "Get a pull request's per-file diff with size caps (20KB/file, 200KB total).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type prFilesDiffArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
type prFileDiffEntry struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
Truncated bool `json:"truncated"`
|
||||
OmittedLines int `json:"omitted_lines,omitempty"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
}
|
||||
|
||||
func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args prFilesDiffArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Number < 1 {
|
||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split unified diff by per-file headers ("diff --git a/path b/path")
|
||||
perFile := splitUnifiedDiff(rawDiff)
|
||||
|
||||
out := struct {
|
||||
Files []prFileDiffEntry `json:"files"`
|
||||
OmittedFiles []string `json:"omitted_files,omitempty"`
|
||||
ResponseTruncated bool `json:"response_truncated"`
|
||||
}{
|
||||
Files: make([]prFileDiffEntry, 0, len(files)),
|
||||
}
|
||||
|
||||
totalBytes := 0
|
||||
for _, f := range files {
|
||||
// look up the diff for this file (best-effort by path match)
|
||||
diffBytes, ok := perFile[f.Filename]
|
||||
if !ok {
|
||||
diffBytes = []byte{}
|
||||
}
|
||||
|
||||
entry := prFileDiffEntry{
|
||||
Path: f.Filename,
|
||||
Additions: f.Additions,
|
||||
Deletions: f.Deletions,
|
||||
}
|
||||
|
||||
// Per-file cap
|
||||
if len(diffBytes) > maxFileDiffBytes {
|
||||
truncated := diffBytes[:maxFileDiffBytes]
|
||||
omittedLines := bytes.Count(diffBytes[maxFileDiffBytes:], []byte("\n"))
|
||||
entry.Diff = string(truncated)
|
||||
entry.Truncated = true
|
||||
entry.OmittedLines = omittedLines
|
||||
} else {
|
||||
entry.Diff = string(diffBytes)
|
||||
}
|
||||
|
||||
// Response cap — if adding this entry would exceed, push to omitted_files
|
||||
entryEstimate := len(entry.Diff) + 200 // small overhead for path + counts
|
||||
if totalBytes+entryEstimate > maxResponseBytes {
|
||||
out.OmittedFiles = append(out.OmittedFiles, f.Filename)
|
||||
out.ResponseTruncated = true
|
||||
continue
|
||||
}
|
||||
totalBytes += entryEstimate
|
||||
out.Files = append(out.Files, entry)
|
||||
}
|
||||
|
||||
return textOK(out)
|
||||
}
|
||||
|
||||
// splitUnifiedDiff parses a unified diff and returns a map from filename to that file's
|
||||
// portion of the diff. The unified diff format starts each file with a line like
|
||||
// "diff --git a/<path> b/<path>".
|
||||
func splitUnifiedDiff(d []byte) map[string][]byte {
|
||||
m := map[string][]byte{}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(d))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // allow long diffs
|
||||
|
||||
var currentFile string
|
||||
var current bytes.Buffer
|
||||
|
||||
flush := func() {
|
||||
if currentFile != "" {
|
||||
// Copy: bytes.Buffer.Bytes() returns the internal slice,
|
||||
// which Reset() then reuses. Without the copy, every map
|
||||
// entry ends up aliased to the last file's data.
|
||||
b := current.Bytes()
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
m[currentFile] = cp
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "diff --git ") {
|
||||
flush()
|
||||
// Parse: "diff --git a/<path> b/<path>"
|
||||
rest := strings.TrimPrefix(line, "diff --git a/")
|
||||
parts := strings.SplitN(rest, " b/", 2)
|
||||
if len(parts) == 2 {
|
||||
currentFile = parts[0]
|
||||
} else {
|
||||
currentFile = ""
|
||||
}
|
||||
}
|
||||
if currentFile != "" {
|
||||
current.WriteString(line)
|
||||
current.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return m
|
||||
}
|
||||
224
internal/tools/pr_files_diff_test.go
Normal file
224
internal/tools/pr_files_diff_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildDiff builds a synthetic unified diff for a set of files.
|
||||
// Each file gets `linesPerFile` added lines.
|
||||
func buildDiff(files []string, linesPerFile int) string {
|
||||
var sb strings.Builder
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", f, f)
|
||||
fmt.Fprintf(&sb, "--- a/%s\n+++ b/%s\n", f, f)
|
||||
fmt.Fprintf(&sb, "@@ -0,0 +1,%d @@\n", linesPerFile)
|
||||
sb.WriteString(strings.Repeat("+abcdefghij\n", linesPerFile))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildFilesJSON builds the JSON list of PullRequestFile objects.
|
||||
func buildFilesJSON(files []string, additions int) string {
|
||||
entries := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
entries[i] = fmt.Sprintf(`{"filename":%q,"status":"modified","additions":%d,"deletions":0}`, f, additions)
|
||||
}
|
||||
return "[" + strings.Join(entries, ",") + "]"
|
||||
}
|
||||
|
||||
// newPRFilesDiffServer creates a test server that serves both the /files and .diff endpoints.
|
||||
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/o/r/pulls/1/files":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(filesJSON))
|
||||
case "/api/v1/repos/o/r/pulls/1.diff":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(rawDiff))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPRFilesDiffSmall(t *testing.T) {
|
||||
// Two files, each ~120 bytes of diff — well under per-file and total caps.
|
||||
fileNames := []string{"main.go", "util.go"}
|
||||
// ~10 lines each = ~120 bytes per file diff
|
||||
rawDiff := buildDiff(fileNames, 10)
|
||||
filesJSON := buildFilesJSON(fileNames, 10)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
} `json:"files"`
|
||||
OmittedFiles []string `json:"omitted_files"`
|
||||
ResponseTruncated bool `json:"response_truncated"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
assert.Len(t, out.Files, 2)
|
||||
assert.Empty(t, out.OmittedFiles)
|
||||
assert.False(t, out.ResponseTruncated)
|
||||
|
||||
for _, f := range out.Files {
|
||||
assert.False(t, f.Truncated, "file %s should not be truncated", f.Path)
|
||||
assert.NotEmpty(t, f.Diff)
|
||||
assert.Equal(t, 10, f.Additions)
|
||||
assert.Equal(t, 0, f.Deletions)
|
||||
}
|
||||
paths := []string{out.Files[0].Path, out.Files[1].Path}
|
||||
assert.ElementsMatch(t, fileNames, paths)
|
||||
}
|
||||
|
||||
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
||||
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
||||
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
||||
// aliased the buffer's backing array and showed the last file's content.
|
||||
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
||||
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
||||
rawDiff := buildDiff(fileNames, 5)
|
||||
filesJSON := buildFilesJSON(fileNames, 5)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
require.Len(t, out.Files, len(fileNames))
|
||||
|
||||
for _, f := range out.Files {
|
||||
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
||||
assert.Contains(t, f.Diff, expected,
|
||||
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
||||
// No other file's header should leak in.
|
||||
for _, other := range fileNames {
|
||||
if other == f.Path {
|
||||
continue
|
||||
}
|
||||
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
||||
assert.NotContains(t, f.Diff, otherHeader,
|
||||
"file %s diff must NOT contain %s's header", f.Path, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||
fileNames := []string{"bigfile.go"}
|
||||
linesPerFile := 2560 // ~30720 bytes > 20KB cap
|
||||
rawDiff := buildDiff(fileNames, linesPerFile)
|
||||
filesJSON := buildFilesJSON(fileNames, linesPerFile)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
Truncated bool `json:"truncated"`
|
||||
OmittedLines int `json:"omitted_lines"`
|
||||
Additions int `json:"additions"`
|
||||
} `json:"files"`
|
||||
ResponseTruncated bool `json:"response_truncated"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
require.Len(t, out.Files, 1)
|
||||
f := out.Files[0]
|
||||
assert.Equal(t, "bigfile.go", f.Path)
|
||||
assert.True(t, f.Truncated, "file should be truncated")
|
||||
assert.Greater(t, f.OmittedLines, 0, "omitted_lines should be > 0")
|
||||
assert.LessOrEqual(t, len(f.Diff), 20*1024+200, "diff should be capped near 20KB")
|
||||
assert.False(t, out.ResponseTruncated)
|
||||
}
|
||||
|
||||
func TestPRFilesDiffResponseCapped(t *testing.T) {
|
||||
// 25 files × ~10KB diff each = ~250KB raw, well over the 200KB response cap.
|
||||
// Each file: 850 lines × 12 bytes = 10200 bytes per file.
|
||||
numFiles := 25
|
||||
linesPerFile := 850
|
||||
fileNames := make([]string, numFiles)
|
||||
for i := range fileNames {
|
||||
fileNames[i] = fmt.Sprintf("file%02d.go", i)
|
||||
}
|
||||
rawDiff := buildDiff(fileNames, linesPerFile)
|
||||
filesJSON := buildFilesJSON(fileNames, linesPerFile)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"files"`
|
||||
OmittedFiles []string `json:"omitted_files"`
|
||||
ResponseTruncated bool `json:"response_truncated"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
|
||||
assert.True(t, out.ResponseTruncated, "response should be truncated")
|
||||
assert.NotEmpty(t, out.OmittedFiles, "some files should be omitted")
|
||||
assert.NotEmpty(t, out.Files, "some files should be included")
|
||||
|
||||
// Total files accounted for should equal numFiles.
|
||||
totalAccountedFor := len(out.Files) + len(out.OmittedFiles)
|
||||
assert.Equal(t, numFiles, totalAccountedFor)
|
||||
}
|
||||
|
||||
func TestPRFilesDiffAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPRFilesDiffRequiresValidNumber(t *testing.T) {
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":0}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
80
internal/tools/pr_list.go
Normal file
80
internal/tools/pr_list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type PRList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewPRList(c *gitea.Client, a *allowlist.Allowlist) *PRList {
|
||||
return &PRList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *PRList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "pr_list",
|
||||
Description: "List pull requests. state: open (default), closed, or all. Optionally filter by head branch.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"state":{"type":"string","enum":["open","closed","all"]},
|
||||
"head":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type prListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Head string `json:"head"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args prListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := args.State
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
|
||||
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(prs))
|
||||
for i, pr := range prs {
|
||||
result[i] = map[string]any{
|
||||
"number": pr.Number,
|
||||
"title": pr.Title,
|
||||
"state": pr.State,
|
||||
"head_branch": pr.Head.Ref,
|
||||
"base_branch": pr.Base.Ref,
|
||||
"draft": pr.Draft,
|
||||
"html_url": pr.HTMLURL,
|
||||
}
|
||||
}
|
||||
return textOK(result)
|
||||
}
|
||||
62
internal/tools/pr_list_test.go
Normal file
62
internal/tools/pr_list_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPRListReturnsOpenPRs(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{
|
||||
"number":7,"title":"Add feature X","html_url":"http://example.com/pulls/7",
|
||||
"state":"open","draft":false,
|
||||
"head":{"ref":"feat/x"},"base":{"ref":"main"}
|
||||
}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, float64(7), result[0]["number"])
|
||||
assert.Equal(t, "feat/x", result[0]["head_branch"])
|
||||
assert.Equal(t, "main", result[0]["base_branch"])
|
||||
}
|
||||
|
||||
func TestPRListDefaultsToOpen(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPRListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewPRList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
76
internal/tools/pr_merge.go
Normal file
76
internal/tools/pr_merge.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type PRMerge struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge {
|
||||
return &PRMerge{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *PRMerge) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "pr_merge",
|
||||
Description: "Merge a pull request. style: merge (default), squash, or rebase.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"index":{"type":"integer","minimum":1},
|
||||
"style":{"type":"string","enum":["merge","squash","rebase"]},
|
||||
"merge_message_title":{"type":"string"},
|
||||
"merge_message_field":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name","index"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type prMergeArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Index int `json:"index"`
|
||||
Style string `json:"style"`
|
||||
Title string `json:"merge_message_title"`
|
||||
Body string `json:"merge_message_field"`
|
||||
}
|
||||
|
||||
func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args prMergeArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Index < 1 {
|
||||
return nil, fmt.Errorf("index must be >= 1: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
style := args.Style
|
||||
if style == "" {
|
||||
style = "merge"
|
||||
}
|
||||
|
||||
if err := t.c.MergePullRequest(ctx, args.Owner, args.Name, args.Index, gitea.MergePRArgs{
|
||||
Do: style,
|
||||
Title: args.Title,
|
||||
Body: args.Body,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textOK(map[string]any{"merged": true})
|
||||
}
|
||||
70
internal/tools/pr_merge_test.go
Normal file
70
internal/tools/pr_merge_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPRMergeSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.Equal(t, true, result["merged"])
|
||||
}
|
||||
|
||||
func TestPRMergeDefaultsToMergeStyle(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
captured, err = io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||
assert.Equal(t, "merge", payload["Do"])
|
||||
}
|
||||
|
||||
func TestPRMergeConflictReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||
}
|
||||
|
||||
func TestPRMergeAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
73
internal/tools/release_create.go
Normal file
73
internal/tools/release_create.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type ReleaseCreate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
|
||||
return &ReleaseCreate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "release_create",
|
||||
Description: "Create a release (and tag if it doesn't exist) for a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
|
||||
"release_name":{"type":"string","description":"Display name for the release."},
|
||||
"body":{"type":"string","description":"Release notes / changelog."},
|
||||
"draft":{"type":"boolean"},
|
||||
"prerelease":{"type":"boolean"},
|
||||
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
|
||||
},
|
||||
"required":["owner","name","tag_name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type releaseCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args releaseCreateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
|
||||
TagName: args.TagName,
|
||||
Name: args.ReleaseName,
|
||||
Body: args.Body,
|
||||
Draft: args.Draft,
|
||||
Prerelease: args.Prerelease,
|
||||
Target: args.Target,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(rel)
|
||||
}
|
||||
38
internal/tools/release_create_test.go
Normal file
38
internal/tools/release_create_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReleaseCreateTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
|
||||
assert.Contains(t, string(out), `"html_url"`)
|
||||
}
|
||||
|
||||
func TestReleaseCreateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
74
internal/tools/repo_create.go
Normal file
74
internal/tools/repo_create.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoCreate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate {
|
||||
return &RepoCreate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoCreate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_create",
|
||||
Description: "Create a repository for the authenticated user or an organisation.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string","description":"Username or org name (used for allowlist check)."},
|
||||
"name":{"type":"string","description":"Repository name."},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean","description":"Create as private. Default false."},
|
||||
"auto_init":{"type":"boolean","description":"Initialise with README."},
|
||||
"default_branch":{"type":"string","description":"Default branch name. Default 'main'."},
|
||||
"is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
IsOrg bool `json:"is_org"`
|
||||
}
|
||||
|
||||
func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoCreateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createArgs := gitea.CreateRepoArgs{
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
AutoInit: args.AutoInit,
|
||||
DefaultBranch: args.DefaultBranch,
|
||||
}
|
||||
if args.IsOrg {
|
||||
createArgs.Org = args.Owner
|
||||
}
|
||||
r, err := t.c.CreateRepo(ctx, createArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(r)
|
||||
}
|
||||
53
internal/tools/repo_create_test.go
Normal file
53
internal/tools/repo_create_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoCreateTool_User(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||
assert.Contains(t, string(out), `"clone_url"`)
|
||||
}
|
||||
|
||||
func TestRepoCreateTool_Org(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`)
|
||||
}
|
||||
|
||||
func TestRepoCreateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
59
internal/tools/repo_delete.go
Normal file
59
internal/tools/repo_delete.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoDelete struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
|
||||
return &RepoDelete{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_delete",
|
||||
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
|
||||
},
|
||||
"required":["owner","name","confirm"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoDeleteArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Confirm != args.Name {
|
||||
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
|
||||
}
|
||||
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
|
||||
}
|
||||
52
internal/tools/repo_delete_test.go
Normal file
52
internal/tools/repo_delete_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
|
||||
deleted := false
|
||||
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)
|
||||
deleted = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, deleted, "DELETE request must have been sent")
|
||||
assert.Contains(t, string(out), "deleted")
|
||||
}
|
||||
|
||||
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
func TestRepoDeleteAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -48,10 +48,8 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Limit == 0 || args.Limit > 50 {
|
||||
args.Limit = 30
|
||||
}
|
||||
if args.Page == 0 {
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit)
|
||||
|
||||
117
internal/tools/repo_mirror_push.go
Normal file
117
internal/tools/repo_mirror_push.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoMirrorPush struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
|
||||
return &RepoMirrorPush{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_mirror_push",
|
||||
Description: "Manage push mirrors for a repository: add, list, or delete.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"action":{"type":"string","enum":["add","list","delete"]},
|
||||
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
|
||||
"remote_username":{"type":"string"},
|
||||
"remote_password":{"type":"string","description":"Never logged or returned."},
|
||||
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
|
||||
"sync_on_commit":{"type":"boolean"},
|
||||
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
|
||||
},
|
||||
"required":["owner","name","action"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoMirrorPushArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
RemoteUsername string `json:"remote_username"`
|
||||
RemotePassword string `json:"remote_password"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
MirrorName string `json:"mirror_name"`
|
||||
}
|
||||
|
||||
// safeMirror omits remote_password so it is never returned to the caller.
|
||||
type safeMirror struct {
|
||||
ID int `json:"id"`
|
||||
RemoteName string `json:"remote_name"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
}
|
||||
|
||||
func toSafeMirror(m *gitea.PushMirror) safeMirror {
|
||||
return safeMirror{
|
||||
ID: m.ID,
|
||||
RemoteName: m.RemoteName,
|
||||
RemoteAddress: m.RemoteAddress,
|
||||
Interval: m.Interval,
|
||||
SyncOnCommit: m.SyncOnCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoMirrorPushArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch args.Action {
|
||||
case "add":
|
||||
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{
|
||||
RemoteAddress: args.RemoteAddress,
|
||||
RemoteUsername: args.RemoteUsername,
|
||||
RemotePassword: args.RemotePassword,
|
||||
Interval: args.Interval,
|
||||
SyncOnCommit: args.SyncOnCommit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(toSafeMirror(m))
|
||||
case "list":
|
||||
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safe := make([]safeMirror, len(mirrors))
|
||||
for i := range mirrors {
|
||||
safe[i] = toSafeMirror(&mirrors[i])
|
||||
}
|
||||
return textOK(safe)
|
||||
case "delete":
|
||||
if args.MirrorName == "" {
|
||||
return nil, fmt.Errorf("mirror_name is required for action=delete")
|
||||
}
|
||||
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
|
||||
}
|
||||
}
|
||||
80
internal/tools/repo_mirror_push_test.go
Normal file
80
internal/tools/repo_mirror_push_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoMirrorPushTool_Add(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||
"owner":"mathias","name":"infra","action":"add",
|
||||
"remote_address":"https://github.com/mathias/infra.git",
|
||||
"remote_username":"mathias","remote_password":"secret",
|
||||
"interval":"8h0m0s","sync_on_commit":true
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
// password must never appear in output
|
||||
assert.NotContains(t, string(out), "secret")
|
||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_List(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_Delete(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), "deleted")
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) {
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mirror_name")
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
90
internal/tools/repo_search.go
Normal file
90
internal/tools/repo_search.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoSearch struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoSearch(c *gitea.Client, a *allowlist.Allowlist) *RepoSearch {
|
||||
return &RepoSearch{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoSearch) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_search",
|
||||
Description: "Search repos by query string. Filters results by owner allowlist.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"q":{"type":"string"},
|
||||
"owner":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["q"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoSearchArgs struct {
|
||||
Q string `json:"q"`
|
||||
Owner string `json:"owner"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoSearchArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Q == "" {
|
||||
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Owner != "" {
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
|
||||
repos, err := t.c.SearchRepos(ctx, args.Q, args.Owner, args.Page, args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-filter when owner not specified — only allowlisted owners survive.
|
||||
if args.Owner == "" {
|
||||
filtered := make([]gitea.Repo, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
parts := strings.SplitN(r.FullName, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if t.a.Check(parts[0]) == nil {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
repos = filtered
|
||||
}
|
||||
|
||||
out := map[string]any{"repos": repos}
|
||||
if len(repos) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
61
internal/tools/repo_search_test.go
Normal file
61
internal/tools/repo_search_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoSearchWithOwner(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
|
||||
assert.Equal(t, "infra", r.URL.Query().Get("q"))
|
||||
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"mathias"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||
}
|
||||
|
||||
func TestRepoSearchPostFiltersWithoutOwner(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// No owner param expected when owner is empty
|
||||
assert.Empty(t, r.URL.Query().Get("owner"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"name":"x","full_name":"mathias/x"},{"name":"y","full_name":"evil/y"}],"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"x"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"mathias/x"`)
|
||||
assert.NotContains(t, string(out), `"evil/y"`)
|
||||
}
|
||||
|
||||
func TestRepoSearchAllowlistRejectsExplicitOwner(t *testing.T) {
|
||||
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"evil"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRepoSearchRequiresQ(t *testing.T) {
|
||||
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{}`))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||
}
|
||||
104
internal/tools/repo_status.go
Normal file
104
internal/tools/repo_status.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoStatus struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoStatus(c *gitea.Client, a *allowlist.Allowlist) *RepoStatus {
|
||||
return &RepoStatus{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoStatus) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_status",
|
||||
Description: "Get repo state in one call: all branches, open PRs, and protection rules for a target branch. Use this first to decide whether to use feature-branch or trunk-based development.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"branch":{"type":"string"}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoStatusArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoStatusArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branch := args.Branch
|
||||
if branch == "" {
|
||||
var err error
|
||||
branch, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, 1, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, "open", "", 1, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branchList := make([]map[string]any, len(branches))
|
||||
for i, b := range branches {
|
||||
branchList[i] = map[string]any{"name": b.Name, "sha": b.Commit.ID}
|
||||
}
|
||||
|
||||
prList := make([]map[string]any, len(prs))
|
||||
for i, pr := range prs {
|
||||
prList[i] = map[string]any{
|
||||
"number": pr.Number,
|
||||
"title": pr.Title,
|
||||
"state": pr.State,
|
||||
"head_branch": pr.Head.Ref,
|
||||
"base_branch": pr.Base.Ref,
|
||||
"draft": pr.Draft,
|
||||
"html_url": pr.HTMLURL,
|
||||
}
|
||||
}
|
||||
|
||||
return textOK(map[string]any{
|
||||
"branches": branchList,
|
||||
"open_prs": prList,
|
||||
"protection": map[string]any{
|
||||
"protected": bp.Protected,
|
||||
"required_approvals": bp.RequiredApprovals,
|
||||
"push_whitelist": bp.PushWhitelist,
|
||||
"merge_whitelist": bp.MergeWhitelist,
|
||||
},
|
||||
})
|
||||
}
|
||||
131
internal/tools/repo_status_test.go
Normal file
131
internal/tools/repo_status_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoStatusComposesThreeEndpoints(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"main","commit":{"id":"abc","url":""}},
|
||||
{"name":"feat/x","commit":{"id":"def","url":""}}
|
||||
]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{
|
||||
"number":3,"title":"My PR","html_url":"http://example.com/pulls/3",
|
||||
"state":"open","draft":false,
|
||||
"head":{"ref":"feat/x"},"base":{"ref":"main"}
|
||||
}]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"required_approvals":1,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
|
||||
branches := result["branches"].([]any)
|
||||
assert.Len(t, branches, 2)
|
||||
|
||||
openPRs := result["open_prs"].([]any)
|
||||
assert.Len(t, openPRs, 1)
|
||||
assert.Equal(t, float64(3), openPRs[0].(map[string]any)["number"])
|
||||
|
||||
protection := result["protection"].(map[string]any)
|
||||
assert.Equal(t, true, protection["protected"])
|
||||
assert.Equal(t, float64(1), protection["required_approvals"])
|
||||
}
|
||||
|
||||
func TestRepoStatusUnprotectedBranch(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","url":""}}]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","branch":"main"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
protection := result["protection"].(map[string]any)
|
||||
assert.Equal(t, false, protection["protected"])
|
||||
}
|
||||
|
||||
func TestRepoStatusAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoStatus(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","branch":"main"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRepoStatusDefaultsBranchFromRepo(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"repo","full_name":"owner/repo","default_branch":"main","description":"","private":false,"clone_url":"","html_url":""}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branches", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","url":""}}]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/owner/repo/branch_protections/main", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"required_approvals":0,"push_whitelist_usernames":[],"merge_whitelist_usernames":[]}`))
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||
// no "branch" field — triggers DefaultBranch fallback
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &result))
|
||||
assert.NotNil(t, result["branches"])
|
||||
assert.NotNil(t, result["open_prs"])
|
||||
assert.NotNil(t, result["protection"])
|
||||
}
|
||||
55
internal/tools/repo_topics_update.go
Normal file
55
internal/tools/repo_topics_update.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoTopicsUpdate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate {
|
||||
return &RepoTopicsUpdate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_topics_update",
|
||||
Description: "Replace the topic list for a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."}
|
||||
},
|
||||
"required":["owner","name","topics"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoTopicsUpdateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
|
||||
func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoTopicsUpdateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]any{"status": "updated", "topics": args.Topics})
|
||||
}
|
||||
35
internal/tools/repo_topics_update_test.go
Normal file
35
internal/tools/repo_topics_update_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoTopicsUpdateTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPut, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), "updated")
|
||||
}
|
||||
|
||||
func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
56
internal/tools/repo_tree.go
Normal file
56
internal/tools/repo_tree.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoTree struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree {
|
||||
return &RepoTree{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoTree) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_tree",
|
||||
Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"ref":{"type":"string","description":"Branch, tag, or commit SHA."}
|
||||
},
|
||||
"required":["owner","name","ref"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoTreeArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoTreeArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(tree)
|
||||
}
|
||||
50
internal/tools/repo_tree_test.go
Normal file
50
internal/tools/repo_tree_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoTreeTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi","size":0}],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"sha":"abc"`)
|
||||
assert.Contains(t, string(out), `"path":"README.md"`)
|
||||
}
|
||||
|
||||
func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRepoTreeAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
91
internal/tools/repo_update.go
Normal file
91
internal/tools/repo_update.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoUpdate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
||||
return &RepoUpdate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_update",
|
||||
Description: "Update repository metadata (description, visibility, default branch, website, archived, template). " +
|
||||
"Only fields explicitly set in the call are patched. " +
|
||||
"WARNING: private=false exposes the repo publicly — verify intent before calling.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
|
||||
"website":{"type":"string","description":"Homepage URL"},
|
||||
"default_branch":{"type":"string","description":"Rename the default branch"},
|
||||
"archived":{"type":"boolean","description":"Mark repo as archived (read-only)."},
|
||||
"template":{"type":"boolean","description":"Toggle template-repo flag"},
|
||||
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoUpdateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,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"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoUpdateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Making a repo public is a significant action — require explicit confirmation.
|
||||
if args.Private != nil && !*args.Private {
|
||||
if args.Confirm != args.Name {
|
||||
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if args.Description == nil && args.Private == nil && args.Website == nil &&
|
||||
args.DefaultBranch == nil && args.Archived == nil && args.Template == nil {
|
||||
return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
Website: args.Website,
|
||||
DefaultBranch: args.DefaultBranch,
|
||||
Archived: args.Archived,
|
||||
Template: args.Template,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(r)
|
||||
}
|
||||
153
internal/tools/repo_update_test.go
Normal file
153
internal/tools/repo_update_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
|
||||
return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
}
|
||||
|
||||
// TestRepoUpdateArchive: happy path — set archived=true.
|
||||
func TestRepoUpdateArchive(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPatch, r.Method)
|
||||
require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path)
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"old-svc","archived":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wire payload only contains the field that was actually set.
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, true, sent["archived"])
|
||||
assert.NotContains(t, sent, "description")
|
||||
assert.NotContains(t, sent, "private")
|
||||
assert.NotContains(t, sent, "website")
|
||||
assert.NotContains(t, sent, "template")
|
||||
|
||||
var repo gitea.Repo
|
||||
require.NoError(t, json.Unmarshal(result, &repo))
|
||||
assert.Equal(t, "mathias/old-svc", repo.FullName)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMultipleFields: set description + template flag in one call.
|
||||
func TestRepoUpdateMultipleFields(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, "Go agent template", sent["description"])
|
||||
assert.Equal(t, true, sent["template"])
|
||||
assert.NotContains(t, sent, "archived")
|
||||
assert.NotContains(t, sent, "private")
|
||||
}
|
||||
|
||||
// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network.
|
||||
func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo"}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMakePublic: private=false requires confirm=<repo name> as a safety
|
||||
// gate (kept from main #21 during the v02-patch merge). With confirm matching, the
|
||||
// patch goes through.
|
||||
func TestRepoUpdateMakePublic(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"open-repo","private":false,"confirm":"open-repo"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, false, sent["private"])
|
||||
}
|
||||
|
||||
// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation.
|
||||
func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"open-repo","private":false}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
||||
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"evil","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream.
|
||||
func TestRepoUpdateUpstreamError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrUpstream)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user