Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
2
.aider.conf.yml
Normal file
2
.aider.conf.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
read: .aider.conventions.md
|
||||||
|
auto-commits: false
|
||||||
318
.aider.conventions.md
Normal file
318
.aider.conventions.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
|
## Default stack
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## Client work rules
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
143
.context/PROJECT.md
Normal file
143
.context/PROJECT.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
22
.context/mcp.json
Normal file
22
.context/mcp.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://infra-mcp.d-ma.be/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
325
.context/system-prompt.txt
Normal file
325
.context/system-prompt.txt
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
You are a coding assistant working on a specific project.
|
||||||
|
Follow all conventions from both the root agent context and project context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
|
## Default stack
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## Client work rules
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
|
|
||||||
|
---
|
||||||
321
.cursorrules
Normal file
321
.cursorrules
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# Cursor rules — auto-generated
|
||||||
|
# Do not edit. Run: task context:sync
|
||||||
|
|
||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
|
## Default stack
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## Client work rules
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
@@ -95,6 +95,7 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
environment: staging
|
||||||
steps:
|
steps:
|
||||||
- name: Update image tag in infra repo
|
- name: Update image tag in infra repo
|
||||||
env:
|
env:
|
||||||
|
|||||||
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.
|
||||||
318
AGENTS.md
Normal file
318
AGENTS.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# Agent context — Mathias workspace
|
||||||
|
|
||||||
|
<!-- Canonical root context for all AI coding agents.
|
||||||
|
Lives at: ~/dev/.context/AGENT.md
|
||||||
|
Applies to every project under ~/dev/ unless overridden.
|
||||||
|
|
||||||
|
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
||||||
|
Project-level context in .context/PROJECT.md layers on top of this. -->
|
||||||
|
|
||||||
|
## Who I am
|
||||||
|
|
||||||
|
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
||||||
|
I build software, research emerging tech, and deliver consulting engagements
|
||||||
|
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
||||||
|
and climate/sustainability tech.
|
||||||
|
|
||||||
|
## How I work with agents
|
||||||
|
|
||||||
|
- I think like a product manager — I care about *why* before *how*
|
||||||
|
- I want agents to be opinionated and push back, not just execute blindly
|
||||||
|
- I prefer concise responses; skip ceremony and get to the point
|
||||||
|
- When I say "build this", I mean production-quality with tests, not a demo
|
||||||
|
- Ask me before making irreversible changes or adding heavy dependencies
|
||||||
|
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||||
|
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||||
|
PR. Do not merge without explicit instruction from Mathias.
|
||||||
|
|
||||||
|
## Default stack
|
||||||
|
|
||||||
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| Language | Go | Python | TypeScript, Java, C |
|
||||||
|
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
||||||
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
|
| Logging | slog (structured) | — | — |
|
||||||
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
|
## Code conventions
|
||||||
|
|
||||||
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
Three machines on Tailscale:
|
||||||
|
|
||||||
|
| Machine | Role | Key specs |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
|
## Project landscape
|
||||||
|
|
||||||
|
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
||||||
|
|
||||||
|
Organized in thematic folders:
|
||||||
|
|
||||||
|
| Folder | Focus | Count |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
|
|
||||||
|
### Key active projects
|
||||||
|
|
||||||
|
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
||||||
|
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
||||||
|
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
||||||
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
|
## Knowledge base
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## Client work rules
|
||||||
|
|
||||||
|
When working on a project tagged with a client name:
|
||||||
|
1. Never send code, data, or context to cloud APIs — use local models only
|
||||||
|
2. Never reference other client projects or their data
|
||||||
|
3. Keep all artifacts within the client's git org / directory
|
||||||
|
4. Treat everything as confidential unless told otherwise
|
||||||
|
|
||||||
|
## Harness-agnostic principles
|
||||||
|
|
||||||
|
This context is designed to work with any AI coding tool:
|
||||||
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
|
|
||||||
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
|
|
||||||
|
## How context propagates
|
||||||
|
|
||||||
|
Canonical sources of truth:
|
||||||
|
- Universal: `~/dev/.context/AGENT.md` (this file)
|
||||||
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||||
|
`.context/system-prompt.txt`
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
|
derived together. Push.
|
||||||
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
|
unconditionally on every host, every harness.
|
||||||
|
|
||||||
|
## Engineering Skills
|
||||||
|
|
||||||
|
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
143
CLAUDE.md
Normal file
143
CLAUDE.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Project context
|
||||||
|
|
||||||
|
<!-- Canonical project context. Edit this, run `task context:sync`.
|
||||||
|
Root agent context from ~/dev/.context/AGENT.md is automatically
|
||||||
|
prepended for harnesses that don't walk the directory tree. -->
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **Name**: gitea-mcp
|
||||||
|
- **Owner**: Mathias
|
||||||
|
- **Client**: personal
|
||||||
|
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
||||||
|
- **Status**: active
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Primary language**: Go
|
||||||
|
- **UI layer**: HTMX + Templ (when applicable)
|
||||||
|
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
||||||
|
- **Build**: Task (taskfile.dev), not Make
|
||||||
|
- **Containers**: Docker (compose for dev, k3s for deploy)
|
||||||
|
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
||||||
|
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
||||||
|
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
||||||
|
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
||||||
|
|
||||||
|
### Architecture preferences
|
||||||
|
- Prefer standard library over frameworks (net/http over gin/echo)
|
||||||
|
- Dependency injection via constructor functions, not containers
|
||||||
|
- Configuration via environment variables, parsed at startup into a typed struct
|
||||||
|
- Structured logging via `slog`
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
|
- Client data never leaves local network unless explicitly cleared
|
||||||
|
- Dependencies: audit with `govulncheck` before adding
|
||||||
|
|
||||||
|
## Knowledge base access
|
||||||
|
|
||||||
|
This project can query the shared knowledge base via MCP or HTTP:
|
||||||
|
|
||||||
|
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
||||||
|
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
||||||
|
- **Scoping**: queries are filtered to collection `personal` + `public`
|
||||||
|
|
||||||
|
## Behavior rules
|
||||||
|
|
||||||
|
These rules apply to every task in this project, regardless of harness.
|
||||||
|
|
||||||
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
|
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
||||||
|
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
||||||
|
files, and formatting alone. Diffs should be small and reviewable.
|
||||||
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
|
||||||
|
## Agent instructions
|
||||||
|
|
||||||
|
When acting as a coding agent on this project:
|
||||||
|
|
||||||
|
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
||||||
|
2. Run `task check` before committing (lint + test + vet)
|
||||||
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
|
4. Never modify files outside the project root without explicit permission
|
||||||
|
5. When adding a dependency, explain why in the commit message
|
||||||
|
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current sprint — gitea-mcp v0.2 (2026-05-14)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
This sprint implements new MCP tools needed for `hyperguild new-project` —
|
||||||
|
the automated project creation flow triggered from claude.ai. See brain knowledge
|
||||||
|
nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline`
|
||||||
|
for full background.
|
||||||
|
|
||||||
|
### Issues to implement (priority order)
|
||||||
|
|
||||||
|
**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos |
|
||||||
|
| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors |
|
||||||
|
| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} |
|
||||||
|
|
||||||
|
**Batch 2 — quality of life (second PR: `feat/repo-ux`)**
|
||||||
|
|
||||||
|
| Issue | Tool | Gitea API |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response |
|
||||||
|
| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true |
|
||||||
|
| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics |
|
||||||
|
|
||||||
|
**Batch 3 — can wait**
|
||||||
|
|
||||||
|
| Issue | Tool | Note |
|
||||||
|
|-------|------|------|
|
||||||
|
| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name |
|
||||||
|
| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases |
|
||||||
|
|
||||||
|
### How to add a tool (pattern)
|
||||||
|
|
||||||
|
Every tool = 4 files following `internal/tools/repo_get.go` exactly:
|
||||||
|
|
||||||
|
1. `internal/gitea/<domain>.go` — API client method (use PostJSON/PatchJSON/DeleteJSON)
|
||||||
|
2. `internal/tools/repo_<name>.go` — tool handler with Descriptor() + Call()
|
||||||
|
3. `internal/tools/repo_<name>_test.go` — table-driven tests with httptest.NewServer
|
||||||
|
4. Registration in main — find where `NewRepoGet` is registered, add new tool same place
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard)
|
||||||
|
- Use `textOK(result)` for success output
|
||||||
|
- For `repo_mirror_push`: NEVER log or return `remote_password` in any output
|
||||||
|
- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name
|
||||||
|
|
||||||
|
### Token permissions needed
|
||||||
|
|
||||||
|
New tools require these additional Gitea token scopes:
|
||||||
|
- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create
|
||||||
|
- `delete_repo` — repo_delete
|
||||||
|
|
||||||
|
Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user`
|
||||||
|
If scopes are missing, update token in Gitea settings before running tests.
|
||||||
|
|
||||||
|
### Definition of done
|
||||||
|
|
||||||
|
- `task check` passes (all tools, all batches)
|
||||||
|
- Each new tool manually callable via `claude mcp call`
|
||||||
|
- PR #1 (batch 1) merged before starting batch 2
|
||||||
|
- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed
|
||||||
41
Taskfile.yml
41
Taskfile.yml
@@ -14,9 +14,42 @@ tasks:
|
|||||||
lint:
|
lint:
|
||||||
desc: Run golangci-lint
|
desc: Run golangci-lint
|
||||||
cmds: [golangci-lint run ./...]
|
cmds: [golangci-lint run ./...]
|
||||||
check:
|
vet:
|
||||||
desc: Lint, vet, and test (used by CI)
|
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
- go test ./... -race -count=1
|
- govulncheck ./... || true
|
||||||
|
|
||||||
|
check:
|
||||||
|
desc: Run all checks (context freshness + lint + test + vet)
|
||||||
|
cmds:
|
||||||
|
- cmd: |
|
||||||
|
if [ -n "${CI:-}" ]; then
|
||||||
|
echo "✓ context sync: skipped in CI"
|
||||||
|
else
|
||||||
|
bash scripts/context-sync.sh
|
||||||
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||||
|
if [ -n "$drift" ]; then
|
||||||
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
|
echo "$drift" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
||||||
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
|
fi
|
||||||
|
- task: lint
|
||||||
|
- task: test
|
||||||
|
- task: vet
|
||||||
|
|
||||||
|
context:sync:
|
||||||
|
desc: Regenerate all harness-specific context files
|
||||||
|
cmds:
|
||||||
|
- bash scripts/context-sync.sh
|
||||||
|
|
||||||
|
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,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,25 +25,49 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
||||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
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.NewReleaseCreate(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
@@ -49,11 +75,30 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(auth.CallerMiddleware(mcpSrv)))
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
|
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
||||||
|
auth.CallerMiddleware(mcpSrv),
|
||||||
|
),
|
||||||
|
))
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
payload := map[string]any{
|
||||||
|
"resource": cfg.MCPResourceURL,
|
||||||
|
"authorization_servers": []string{},
|
||||||
|
}
|
||||||
|
if cfg.DexIssuerURL != "" {
|
||||||
|
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
})
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
||||||
|
|||||||
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
@@ -2,10 +2,24 @@ module gitea.d-ma.be/mathias/gitea-mcp
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -1,11 +1,39 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
42
internal/auth/bearer.go
Normal file
42
internal/auth/bearer.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BearerMiddleware authenticates requests via the Authorization header.
|
||||||
|
//
|
||||||
|
// A request is allowed when:
|
||||||
|
//
|
||||||
|
// 1. The Bearer token is a valid JWT issued by the configured Dex OIDC server, or
|
||||||
|
// 2. The Bearer token matches staticToken (constant-time compare).
|
||||||
|
//
|
||||||
|
// Any other case — including missing or empty Authorization header — returns 401.
|
||||||
|
//
|
||||||
|
// The Gitea service PAT is intentionally NOT used to authenticate the caller:
|
||||||
|
// it is only used by the Gitea client for upstream API calls. Decoupling the
|
||||||
|
// two prevents the MCP endpoint from being reachable anonymously when a service
|
||||||
|
// PAT happens to be configured.
|
||||||
|
func BearerMiddleware(jwtValidator *JWTValidator, staticToken string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !hasBearer || bearer == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jwtValidator.Validate(r.Context(), bearer) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
92
internal/auth/bearer_test.go
Normal file
92
internal/auth/bearer_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func okHandler(called *bool) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if called != nil {
|
||||||
|
*called = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_NoAuthHeader(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_NoAuthHeader_RejectsEvenWhenStaticConfigured(t *testing.T) {
|
||||||
|
// A configured staticToken must not allow unauthenticated callers through.
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "any-static", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_EmptyBearer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "static", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer ")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_StaticToken_Valid(t *testing.T) {
|
||||||
|
const staticToken = "my-static-token"
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, okHandler(&called)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+staticToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_UnknownBearer_NoStatic_NoJWT(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer random-unknown-token")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
79
internal/auth/jwt.go
Normal file
79
internal/auth/jwt.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
|
||||||
|
// A nil JWTValidator always returns false — JWT validation is disabled.
|
||||||
|
type JWTValidator struct {
|
||||||
|
issuer string
|
||||||
|
aud string
|
||||||
|
cache *jwk.Cache
|
||||||
|
jwksURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTValidator creates a validator by fetching the OIDC discovery document
|
||||||
|
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
|
||||||
|
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
||||||
|
if issuerURL == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := jwk.NewCache(ctx)
|
||||||
|
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
|
||||||
|
return nil, fmt.Errorf("register jwks uri: %w", err)
|
||||||
|
}
|
||||||
|
// warm the cache immediately so first request doesn't block
|
||||||
|
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||||
|
return nil, fmt.Errorf("warm jwks cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JWTValidator{
|
||||||
|
issuer: issuerURL,
|
||||||
|
aud: audience,
|
||||||
|
cache: cache,
|
||||||
|
jwksURI: doc.JWKSURI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
|
||||||
|
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
opts := []jwt.ParseOption{
|
||||||
|
jwt.WithKeySet(keySet),
|
||||||
|
jwt.WithIssuer(v.issuer),
|
||||||
|
jwt.WithValidate(true),
|
||||||
|
}
|
||||||
|
if v.aud != "" {
|
||||||
|
opts = append(opts, jwt.WithAudience(v.aud))
|
||||||
|
}
|
||||||
|
_, err = jwt.Parse([]byte(rawToken), opts...)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -8,18 +8,26 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Port string // GITEA_MCP_PORT, default 8080
|
Port string // GITEA_MCP_PORT, default 8080
|
||||||
GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be
|
GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be
|
||||||
GiteaAPIToken string // GITEA_API_TOKEN — bot user token
|
DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, service PAT; used by Gitea client for all upstream calls
|
||||||
|
StaticToken string // GITEA_MCP_STATIC_TOKEN, optional static bearer for service-to-service auth
|
||||||
AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias"
|
AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias"
|
||||||
OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated
|
OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated
|
||||||
|
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
|
||||||
|
MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai
|
||||||
|
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Port: envOr("GITEA_MCP_PORT", "8080"),
|
Port: envOr("GITEA_MCP_PORT", "8080"),
|
||||||
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
|
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
|
||||||
GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"),
|
DefaultToken: os.Getenv("GITEA_MCP_DEFAULT_TOKEN"),
|
||||||
|
StaticToken: os.Getenv("GITEA_MCP_STATIC_TOKEN"),
|
||||||
AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")),
|
AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")),
|
||||||
OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")),
|
OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")),
|
||||||
|
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
|
||||||
|
MCPAudience: os.Getenv("MCP_AUDIENCE"),
|
||||||
|
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
func TestLoadDefaults(t *testing.T) {
|
func TestLoadDefaults(t *testing.T) {
|
||||||
t.Setenv("GITEA_BASE_URL", "")
|
t.Setenv("GITEA_BASE_URL", "")
|
||||||
t.Setenv("GITEA_API_TOKEN", "")
|
|
||||||
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
|
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
|
||||||
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
|
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
|
||||||
t.Setenv("GITEA_MCP_PORT", "")
|
t.Setenv("GITEA_MCP_PORT", "")
|
||||||
@@ -23,7 +22,6 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be")
|
t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be")
|
||||||
t.Setenv("GITEA_API_TOKEN", "test-token")
|
|
||||||
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme")
|
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme")
|
||||||
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
|
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
|
||||||
t.Setenv("GITEA_MCP_PORT", "9000")
|
t.Setenv("GITEA_MCP_PORT", "9000")
|
||||||
@@ -31,7 +29,6 @@ func TestLoadFromEnv(t *testing.T) {
|
|||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL)
|
assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL)
|
||||||
assert.Equal(t, "test-token", cfg.GiteaAPIToken)
|
|
||||||
assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners)
|
assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners)
|
||||||
assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
|
assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
|
||||||
assert.Equal(t, "9000", cfg.Port)
|
assert.Equal(t, "9000", cfg.Port)
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
token := c.token
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -95,6 +96,10 @@ func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, erro
|
|||||||
return c.do(ctx, http.MethodDelete, path, nil)
|
return c.do(ctx, http.MethodDelete, path, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteJSONBody(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
||||||
|
return c.do(ctx, http.MethodDelete, path, body)
|
||||||
|
}
|
||||||
|
|
||||||
type rawResponse struct {
|
type rawResponse struct {
|
||||||
Body []byte
|
Body []byte
|
||||||
Status int
|
Status int
|
||||||
@@ -110,8 +115,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
token := c.token
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileContents struct {
|
type FileContents struct {
|
||||||
@@ -26,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
|
|||||||
if err := MapStatus(status, body); err != nil {
|
if err := MapStatus(status, body); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Array response means path is a directory — guide caller to dir_list.
|
||||||
|
if len(body) > 0 && body[0] == '[' {
|
||||||
|
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
|
||||||
|
}
|
||||||
var fc FileContents
|
var fc FileContents
|
||||||
if err := json.Unmarshal(body, &fc); err != nil {
|
if err := json.Unmarshal(body, &fc); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -92,13 +97,138 @@ type FileWriteResult struct {
|
|||||||
} `json:"commit"`
|
} `json:"commit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UpsertFile(ctx context.Context, owner, repo, path string, args UpsertFileArgs) (*FileWriteResult, error) {
|
func (c *Client) ListBranches(ctx context.Context, owner, repo string, page, limit int) ([]Branch, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches?page=%d&limit=%d", owner, repo, page, limit)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var branches []Branch
|
||||||
|
if err := json.Unmarshal(body, &branches); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return branches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteBranch(ctx context.Context, owner, repo, branch string) error {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", owner, repo, branch)
|
||||||
|
body, status, err := c.DeleteJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return MapStatus(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BranchProtection struct {
|
||||||
|
Protected bool `json:"-"`
|
||||||
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
|
PushWhitelist []string `json:"push_whitelist_usernames"`
|
||||||
|
MergeWhitelist []string `json:"merge_whitelist_usernames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetBranchProtection(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, branch)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == 404 {
|
||||||
|
return &BranchProtection{Protected: false}, nil
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var bp BranchProtection
|
||||||
|
if err := json.Unmarshal(body, &bp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bp.Protected = true
|
||||||
|
return &bp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path, ref string) ([]DirEntry, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||||
|
if ref != "" {
|
||||||
|
p += "?ref=" + url.QueryEscape(ref)
|
||||||
|
}
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) > 0 && body[0] == '{' {
|
||||||
|
return nil, fmt.Errorf("path is a file, not a directory — use file_read: %w", ErrValidation)
|
||||||
|
}
|
||||||
|
var entries []DirEntry
|
||||||
|
if err := json.Unmarshal(body, &entries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteFileArgs struct {
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteFile(ctx context.Context, owner, repo, path string, args DeleteFileArgs) (*FileWriteResult, error) {
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err := c.PutJSON(ctx, p, payload)
|
body, status, err := c.DeleteJSONBody(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out FileWriteResult
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertFile creates a file when args.Sha is empty (POST) or updates an existing
|
||||||
|
// file when args.Sha is set (PUT). Gitea routes both operations by HTTP method on
|
||||||
|
// the same /contents/{path} URL, and rejects PUT without a sha.
|
||||||
|
func (c *Client) UpsertFile(ctx context.Context, owner, repo, path string, args UpsertFileArgs) (*FileWriteResult, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
body []byte
|
||||||
|
status int
|
||||||
|
)
|
||||||
|
if args.Sha == "" {
|
||||||
|
body, status, err = c.PostJSON(ctx, p, payload)
|
||||||
|
} else {
|
||||||
|
body, status, err = c.PutJSON(ctx, p, payload)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,28 @@ func TestCreateBranchSendsPayload(t *testing.T) {
|
|||||||
assert.Equal(t, "main", payload["old_branch_name"])
|
assert.Equal(t, "main", payload["old_branch_name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListBranches(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/branches", r.URL.Path)
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("page"))
|
||||||
|
assert.Equal(t, "30", r.URL.Query().Get("limit"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"name":"main","commit":{"id":"abc","url":"http://example.com"}},
|
||||||
|
{"name":"feat/x","commit":{"id":"def","url":"http://example.com"}}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
branches, err := c.ListBranches(context.Background(), "o", "r", 0, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, branches, 2)
|
||||||
|
assert.Equal(t, "main", branches[0].Name)
|
||||||
|
assert.Equal(t, "abc", branches[0].Commit.ID)
|
||||||
|
assert.Equal(t, "feat/x", branches[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) {
|
func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) {
|
||||||
var captured []byte
|
var captured []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -116,3 +138,130 @@ func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) {
|
|||||||
assert.Equal(t, "http://example.com/p.md", result.Content.HTMLURL)
|
assert.Equal(t, "http://example.com/p.md", result.Content.HTMLURL)
|
||||||
assert.Equal(t, "abc", result.Commit.Sha)
|
assert.Equal(t, "abc", result.Commit.Sha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteBranch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/branches/feat/x", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteBranch(context.Background(), "o", "r", "feat/x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteBranchProtected(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"branch is protected"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteBranch(context.Background(), "o", "r", "main")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrPermissionDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBranchProtectionFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/branch_protections/main", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"required_approvals": 2,
|
||||||
|
"push_whitelist_usernames": ["alice"],
|
||||||
|
"merge_whitelist_usernames": ["bob"]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
bp, err := c.GetBranchProtection(context.Background(), "o", "r", "main")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, bp.Protected)
|
||||||
|
assert.Equal(t, int64(2), bp.RequiredApprovals)
|
||||||
|
assert.Equal(t, []string{"alice"}, bp.PushWhitelist)
|
||||||
|
assert.Equal(t, []string{"bob"}, bp.MergeWhitelist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBranchProtectionNotFoundReturnsUnprotected(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
bp, err := c.GetBranchProtection(context.Background(), "o", "r", "feat/x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, bp.Protected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListContentsDirectory(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/contents/src", r.URL.Path)
|
||||||
|
assert.Equal(t, "main", r.URL.Query().Get("ref"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"name":"main.go","path":"src/main.go","type":"file","sha":"abc","size":100},
|
||||||
|
{"name":"lib","path":"src/lib","type":"dir","sha":"def","size":0}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
entries, err := c.ListContents(context.Background(), "o", "r", "src", "main")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "main.go", entries[0].Name)
|
||||||
|
assert.Equal(t, "file", entries[0].Type)
|
||||||
|
assert.Equal(t, "lib", entries[1].Name)
|
||||||
|
assert.Equal(t, "dir", entries[1].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListContentsOnFileReturnsError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"main.go","sha":"abc","size":100,"content":"","encoding":"base64"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.ListContents(context.Background(), "o", "r", "main.go", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFile(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/contents/src/old.go", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"content":null,
|
||||||
|
"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
result, err := c.DeleteFile(context.Background(), "o", "r", "src/old.go", gitea.DeleteFileArgs{
|
||||||
|
Branch: "main",
|
||||||
|
Message: "remove old.go",
|
||||||
|
Sha: "blobsha",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "cmt1", result.Commit.Sha)
|
||||||
|
|
||||||
|
var payload map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "main", payload["branch"])
|
||||||
|
assert.Equal(t, "remove old.go", payload["message"])
|
||||||
|
assert.Equal(t, "blobsha", payload["sha"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
Assignees []User `json:"assignees"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateIssueArgs struct {
|
type CreateIssueArgs struct {
|
||||||
@@ -22,6 +36,22 @@ type CreateIssueArgs struct {
|
|||||||
Milestone int64 `json:"milestone,omitempty"`
|
Milestone int64 `json:"milestone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
|
|||||||
@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
|
|||||||
assert.Equal(t, "open", iss.State)
|
assert.Equal(t, "open", iss.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIssue(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "fix auth", iss.Title)
|
||||||
|
assert.Equal(t, "open", iss.State)
|
||||||
|
assert.Equal(t, 3, iss.Comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateIssueComment(t *testing.T) {
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
var captured []byte
|
var captured []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PullRequest struct {
|
type PullRequest struct {
|
||||||
@@ -101,3 +102,48 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, ind
|
|||||||
}
|
}
|
||||||
return resp.Body, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,3 +136,55 @@ func TestGetPullRequestDiff(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte(rawDiff), diff)
|
assert.Equal(t, []byte(rawDiff), diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListPullRequests(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path)
|
||||||
|
assert.Equal(t, "open", r.URL.Query().Get("state"))
|
||||||
|
assert.Equal(t, "feat/x", r.URL.Query().Get("head"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[` + pullFixture + `]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
prs, err := c.ListPullRequests(context.Background(), "o", "r", "open", "feat/x", 0, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, prs, 1)
|
||||||
|
assert.Equal(t, 7, prs[0].Number)
|
||||||
|
assert.Equal(t, "feat/x", prs[0].Head.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergePullRequestSuccess(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/pulls/7/merge", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "squash"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "squash", payload["Do"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergePullRequestConflict(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.MergePullRequest(context.Background(), "o", "r", 7, gitea.MergePRArgs{Do: "merge"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,109 @@ type Repo struct {
|
|||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "blob" or "tree"
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tree struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tree []TreeEntry `json:"tree"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
|
||||||
|
if recursive {
|
||||||
|
path += "?recursive=1"
|
||||||
|
}
|
||||||
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var t Tree
|
||||||
|
if err := json.Unmarshal(body, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReleaseArgs struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Draft bool `json:"draft,omitempty"`
|
||||||
|
Prerelease bool `json:"prerelease,omitempty"`
|
||||||
|
// Target branch or commit SHA for tag creation. Empty = repo default branch.
|
||||||
|
Target string `json:"target_commitish,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner, repo)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Release
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
|
||||||
|
resp, status, err := c.DeleteJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
||||||
|
body, err := json.Marshal(map[string][]string{"topics": topics})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PutJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -71,6 +174,70 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
|
|||||||
return env.Data, nil
|
return env.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateRepoArgs struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Private bool `json:"private,omitempty"`
|
||||||
|
AutoInit bool `json:"auto_init,omitempty"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
// Org, when non-empty, creates the repo under the named organisation.
|
||||||
|
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
||||||
|
Org string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
||||||
|
var path string
|
||||||
|
if args.Org != "" {
|
||||||
|
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
||||||
|
} else {
|
||||||
|
path = "/api/v1/user/repos"
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
||||||
|
type UpdateRepoArgs struct {
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
|||||||
@@ -47,6 +47,129 @@ func TestListRepos(t *testing.T) {
|
|||||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_User(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
|
Name: "infra",
|
||||||
|
Private: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "mathias/infra", r.FullName)
|
||||||
|
assert.Equal(t, "main", r.DefaultBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_Org(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
|
Name: "infra",
|
||||||
|
Org: "hyperguild",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
desc := "updated"
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
|
||||||
|
Description: &desc,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "updated", r.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTree(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abc", tree.SHA)
|
||||||
|
require.Len(t, tree.Tree, 2)
|
||||||
|
assert.Equal(t, "README.md", tree.Tree[0].Path)
|
||||||
|
assert.Equal(t, "blob", tree.Tree[0].Type)
|
||||||
|
assert.Equal(t, int64(13), tree.Tree[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTopics(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRelease(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||||
|
TagName: "v1.0.0",
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Body: "first release",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||||
|
assert.Equal(t, "first release", rel.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||||
var hits int32
|
var hits int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
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"])
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProtocolVersion = "2025-06-18"
|
ProtocolVersion = "2025-03-26"
|
||||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +31,9 @@ func NewServer(opts ServerOptions) *Server {
|
|||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
case http.MethodHead:
|
||||||
|
w.Header().Set("MCP-Protocol-Version", ProtocolVersion)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
s.handleGET(w, r)
|
s.handleGET(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
@@ -53,7 +56,6 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize is the only method allowed without a session.
|
|
||||||
if req.Method == "initialize" {
|
if req.Method == "initialize" {
|
||||||
sid := s.opts.Sessions.Issue()
|
sid := s.opts.Sessions.Issue()
|
||||||
w.Header().Set("Mcp-Session-Id", sid)
|
w.Header().Set("Mcp-Session-Id", sid)
|
||||||
@@ -65,11 +67,12 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sid := r.Header.Get("Mcp-Session-Id")
|
// Mcp-Session-Id is advisory: we issue one on initialize and accept it back,
|
||||||
if !s.opts.Sessions.Valid(sid) {
|
// but every tool the gitea-mcp server exposes is stateless single-shot, so
|
||||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
// we do not gate non-initialize calls on it. The claude.ai connector's
|
||||||
return
|
// transport proxy is observed to not propagate the session header reliably,
|
||||||
}
|
// and the spec allows servers to be sessionless. Compare with brain-mcp /
|
||||||
|
// supervisor-mcp, which never required a session at all.
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "tools/list":
|
case "tools/list":
|
||||||
@@ -108,11 +111,8 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
|
||||||
sid := r.Header.Get("Mcp-Session-Id")
|
// Session ID is optional for GET: clients may open the SSE stream before
|
||||||
if !s.opts.Sessions.Valid(sid) {
|
// calling initialize (e.g. claude.ai probes on add). Accept with or without.
|
||||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|||||||
@@ -52,19 +52,27 @@ func TestInitialize(t *testing.T) {
|
|||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
result := resp["result"].(map[string]any)
|
result := resp["result"].(map[string]any)
|
||||||
assert.Equal(t, "2025-06-18", result["protocolVersion"])
|
assert.Equal(t, mcp.ProtocolVersion, result["protocolVersion"])
|
||||||
si := result["serverInfo"].(map[string]any)
|
si := result["serverInfo"].(map[string]any)
|
||||||
assert.Equal(t, "gitea-mcp", si["name"])
|
assert.Equal(t, "gitea-mcp", si["name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPostWithoutSessionRejected(t *testing.T) {
|
func TestPostWithoutSessionAccepted(t *testing.T) {
|
||||||
|
// gitea-mcp tools are stateless single-shot; Mcp-Session-Id is advisory.
|
||||||
|
// claude.ai's MCP transport proxy is observed to not propagate the
|
||||||
|
// session header reliably, so non-initialize calls must work without it.
|
||||||
srv := newServer(t)
|
srv := newServer(t)
|
||||||
rr := postJSON(t, srv, map[string]any{
|
rr := postJSON(t, srv, map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"method": "tools/list",
|
"method": "tools/list",
|
||||||
}, "")
|
}, "")
|
||||||
require.Equal(t, http.StatusBadRequest, rr.Code)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
assert.Contains(t, result, "tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {
|
func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {
|
||||||
@@ -118,6 +126,15 @@ func TestPostBodyTooLarge(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHEADReturnsMCPProtocolVersionHeader(t *testing.T) {
|
||||||
|
srv := newServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodHead, "/mcp", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Equal(t, mcp.ProtocolVersion, rr.Header().Get("MCP-Protocol-Version"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolsCallToolNotFound(t *testing.T) {
|
func TestToolsCallToolNotFound(t *testing.T) {
|
||||||
srv := newServer(t)
|
srv := newServer(t)
|
||||||
// Initialize to get a session ID.
|
// Initialize to get a session ID.
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
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,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
|
|||||||
assert.Equal(t, "main", result["ref"])
|
assert.Equal(t, "main", result["ref"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Gitea returns an array when path is a directory
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "directory")
|
||||||
|
assert.Contains(t, err.Error(), "dir_list")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileReadAllowlistRejects(t *testing.T) {
|
func TestFileReadAllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
|||||||
_, _ = w.Write([]byte(createBranchResp))
|
_, _ = w.Write([]byte(createBranchResp))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upsert file → 201
|
// New file (no sha) → POST to /contents/{path}
|
||||||
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, http.MethodPut, r.Method)
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = w.Write([]byte(upsertFileResp))
|
_, _ = w.Write([]byte(upsertFileResp))
|
||||||
})
|
})
|
||||||
@@ -64,6 +64,39 @@ func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
|||||||
assert.Equal(t, "cmt1", result["commit_sha"])
|
assert.Equal(t, "cmt1", result["commit_sha"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileWriteBranchUsesPutWhenShaProvided(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Branch exists
|
||||||
|
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches/feat/existing", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(branchCheckExistsResp))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Existing file (sha provided) → PUT
|
||||||
|
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPut, r.Method)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(upsertFileResp))
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||||
|
"owner":"owner","name":"myrepo","path":"doc.md",
|
||||||
|
"content":"hello","branch":"feat/existing",
|
||||||
|
"sha":"oldsha","message":"update doc.md"
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "feat/existing", result["branch"])
|
||||||
|
assert.Equal(t, "cmt1", result["commit_sha"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileWriteBranchUsesDefaultBaseWhenBaseEmpty(t *testing.T) {
|
func TestFileWriteBranchUsesDefaultBaseWhenBaseEmpty(t *testing.T) {
|
||||||
var createBody []byte
|
var createBody []byte
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
|||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
if currentFile != "" {
|
if currentFile != "" {
|
||||||
m[currentFile] = []byte(current.String())
|
m[currentFile] = current.Bytes()
|
||||||
current.Reset()
|
current.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ func buildFilesJSON(files []string, additions int) string {
|
|||||||
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
|
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch r.URL.Path {
|
||||||
case r.URL.Path == "/api/v1/repos/o/r/pulls/1/files":
|
case "/api/v1/repos/o/r/pulls/1/files":
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(filesJSON))
|
_, _ = w.Write([]byte(filesJSON))
|
||||||
case r.URL.Path == "/api/v1/repos/o/r/pulls/1.diff":
|
case "/api/v1/repos/o/r/pulls/1.diff":
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
_, _ = w.Write([]byte(rawDiff))
|
_, _ = w.Write([]byte(rawDiff))
|
||||||
default:
|
default:
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
76
internal/tools/repo_update.go
Normal file
76
internal/tools/repo_update.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 RepoUpdate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
||||||
|
return &RepoUpdate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_update",
|
||||||
|
Description: "Update repository metadata (description, visibility, default branch, website).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"description":{"type":"string"},
|
||||||
|
"private":{"type":"boolean"},
|
||||||
|
"website":{"type":"string"},
|
||||||
|
"default_branch":{"type":"string"},
|
||||||
|
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
|
||||||
|
},
|
||||||
|
"required":["owner","name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoUpdateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Private *bool `json:"private"`
|
||||||
|
Website *string `json:"website"`
|
||||||
|
DefaultBranch *string `json:"default_branch"`
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoUpdateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Making a repo public is a significant action — require explicit confirmation.
|
||||||
|
if args.Private != nil && !*args.Private {
|
||||||
|
if args.Confirm != args.Name {
|
||||||
|
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
|
||||||
|
Description: args.Description,
|
||||||
|
Private: args.Private,
|
||||||
|
Website: args.Website,
|
||||||
|
DefaultBranch: args.DefaultBranch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(r)
|
||||||
|
}
|
||||||
56
internal/tools/repo_update_test.go
Normal file
56
internal/tools/repo_update_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoUpdateTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"description":"updated"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) {
|
||||||
|
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "confirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
76
internal/tools/tag_create.go
Normal file
76
internal/tools/tag_create.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 TagCreate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTagCreate(c *gitea.Client, a *allowlist.Allowlist) *TagCreate {
|
||||||
|
return &TagCreate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TagCreate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "tag_create",
|
||||||
|
Description: "Create a tag pointing at a branch or commit SHA. Add a message to create an annotated tag.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"tag":{"type":"string"},
|
||||||
|
"target":{"type":"string"},
|
||||||
|
"message":{"type":"string"}
|
||||||
|
},
|
||||||
|
"required":["owner","name","tag","target"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagCreateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args tagCreateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Tag == "" {
|
||||||
|
return nil, fmt.Errorf("tag is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Target == "" {
|
||||||
|
return nil, fmt.Errorf("target is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := t.c.CreateTag(ctx, args.Owner, args.Name, gitea.CreateTagArgs{
|
||||||
|
TagName: args.Tag,
|
||||||
|
Target: args.Target,
|
||||||
|
Message: args.Message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"tag": tag.Name,
|
||||||
|
"commit_sha": tag.Commit.Sha,
|
||||||
|
})
|
||||||
|
}
|
||||||
52
internal/tools/tag_create_test.go
Normal file
52
internal/tools/tag_create_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 TestTagCreateSuccess(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/owner/repo/tags", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"name":"v2.0.0","id":"tagsha",
|
||||||
|
"commit":{"sha":"cmt1","url":""}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewTagCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||||
|
"owner":"owner","name":"repo","tag":"v2.0.0","target":"main"
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "v2.0.0", result["tag"])
|
||||||
|
assert.Equal(t, "cmt1", result["commit_sha"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagCreateRequiresTag(t *testing.T) {
|
||||||
|
tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","target":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagCreateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewTagCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","tag":"v1.0.0","target":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
@@ -21,8 +20,6 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
return json.Unmarshal(raw, dst)
|
return json.Unmarshal(raw, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _ctx(ctx context.Context) context.Context { return ctx } // stub for future hooks
|
|
||||||
|
|
||||||
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
|
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
|
||||||
func capLimit(in, def int) int {
|
func capLimit(in, def int) int {
|
||||||
if in <= 0 {
|
if in <= 0 {
|
||||||
|
|||||||
201
scripts/context-sync.sh
Executable file
201
scripts/context-sync.sh
Executable file
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates harness-specific context files from .context/PROJECT.md
|
||||||
|
# Project-level script — run from a project directory.
|
||||||
|
#
|
||||||
|
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||||
|
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
|
||||||
|
# Task: task context:sync
|
||||||
|
#
|
||||||
|
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Parse --force flag and collect adapter names separately
|
||||||
|
FORCE=false
|
||||||
|
ADAPTERS=()
|
||||||
|
for _arg in "$@"; do
|
||||||
|
case "$_arg" in
|
||||||
|
--force) FORCE=true ;;
|
||||||
|
*) ADAPTERS+=("$_arg") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
PROJECT_FILE=".context/PROJECT.md"
|
||||||
|
|
||||||
|
# Walk up to find root .context/AGENT.md
|
||||||
|
find_root_context() {
|
||||||
|
local dir
|
||||||
|
dir="$(pwd)"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
if [ -f "$dir/.context/AGENT.md" ]; then
|
||||||
|
echo "$dir/.context/AGENT.md"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT_CONTEXT="${ROOT_CONTEXT:-$(find_root_context)}"
|
||||||
|
|
||||||
|
if [ ! -f "$PROJECT_FILE" ]; then
|
||||||
|
echo "Error: $PROJECT_FILE not found. Are you in a project root?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||||
|
if [ "$FORCE" = false ]; then
|
||||||
|
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$_placeholders" ]; then
|
||||||
|
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||||
|
while IFS= read -r _match; do
|
||||||
|
_lineno="${_match%%:*}"
|
||||||
|
_content="${_match#*:}"
|
||||||
|
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||||
|
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||||
|
done <<< "$_placeholders"
|
||||||
|
echo "" >&2
|
||||||
|
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||||
|
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
|
else
|
||||||
|
echo " No root AGENT.md found (project context only)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Emit root context + separator
|
||||||
|
root_block() {
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
cat "$ROOT_CONTEXT"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Claude Code ──────────────────────────────────────────────
|
||||||
|
# Claude Code walks up the tree — it finds ~/dev/CLAUDE.md automatically.
|
||||||
|
# Project-level CLAUDE.md only needs project-specific context.
|
||||||
|
generate_claude() {
|
||||||
|
cat "$PROJECT_FILE" > CLAUDE.md
|
||||||
|
echo " → CLAUDE.md (project-only; Claude Code inherits root)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── AGENTS.md (Crush, Pi, Antigravity) ──────────────────────
|
||||||
|
# These tools read AGENTS.md from cwd but don't walk up.
|
||||||
|
# Concatenate root + project.
|
||||||
|
generate_agents() {
|
||||||
|
{ root_block; cat "$PROJECT_FILE"; } > AGENTS.md
|
||||||
|
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cursor ───────────────────────────────────────────────────
|
||||||
|
generate_cursor() {
|
||||||
|
{
|
||||||
|
echo "# Cursor rules — auto-generated"
|
||||||
|
echo "# Do not edit. Run: task context:sync"
|
||||||
|
echo ""
|
||||||
|
root_block
|
||||||
|
cat "$PROJECT_FILE"
|
||||||
|
} > .cursorrules
|
||||||
|
echo " → .cursorrules (root + project)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Aider ────────────────────────────────────────────────────
|
||||||
|
generate_aider() {
|
||||||
|
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
|
||||||
|
if [ ! -f .aider.conf.yml ]; then
|
||||||
|
cat > .aider.conf.yml << 'YAML'
|
||||||
|
read: .aider.conventions.md
|
||||||
|
auto-commits: false
|
||||||
|
YAML
|
||||||
|
fi
|
||||||
|
echo " → .aider.conventions.md (root + project)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
||||||
|
generate_system_prompt() {
|
||||||
|
{
|
||||||
|
echo "You are a coding assistant working on a specific project."
|
||||||
|
echo "Follow all conventions from both the root agent context and project context."
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
root_block
|
||||||
|
cat "$PROJECT_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
} > .context/system-prompt.txt
|
||||||
|
echo " → .context/system-prompt.txt (root + project)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── MCP config ───────────────────────────────────────────────
|
||||||
|
generate_mcp() {
|
||||||
|
# Ensure baseline file exists with project-specific knowledge server
|
||||||
|
if [ ! -f .context/mcp.json ]; then
|
||||||
|
cat > .context/mcp.json << 'JSON'
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"knowledge": {
|
||||||
|
"url": "http://localhost:3100/mcp",
|
||||||
|
"description": "Project knowledge base — vector + graph retrieval"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||||
|
local root_mcp=""
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
local candidate
|
||||||
|
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||||
|
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$root_mcp" ]; then
|
||||||
|
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Root servers take precedence over project entries on key conflict
|
||||||
|
local root_servers count updated
|
||||||
|
root_servers=$(jq '.servers' "$root_mcp")
|
||||||
|
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||||
|
updated=$(jq --argjson root "$root_servers" \
|
||||||
|
'.mcpServers = (.mcpServers + $root)' \
|
||||||
|
.context/mcp.json)
|
||||||
|
printf '%s\n' "$updated" > .context/mcp.json
|
||||||
|
echo " → .context/mcp.json (merged $count root servers)"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Syncing project context from $PROJECT_FILE..."
|
||||||
|
|
||||||
|
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
||||||
|
generate_claude
|
||||||
|
generate_agents
|
||||||
|
generate_cursor
|
||||||
|
generate_aider
|
||||||
|
generate_system_prompt
|
||||||
|
generate_mcp
|
||||||
|
else
|
||||||
|
for adapter in "${ADAPTERS[@]}"; do
|
||||||
|
case "$adapter" in
|
||||||
|
claude) generate_claude ;;
|
||||||
|
agents) generate_agents ;;
|
||||||
|
cursor) generate_cursor ;;
|
||||||
|
aider) generate_aider ;;
|
||||||
|
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||||
|
mcp) generate_mcp ;;
|
||||||
|
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
Reference in New Issue
Block a user