Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7ccc8295 | ||
|
|
4d6597639e | ||
|
|
419ae19cdb | ||
|
|
e4d07c106c | ||
|
|
1d75bf9b25 | ||
|
|
417ab60707 | ||
|
|
f6446c2515 | ||
|
|
50c447f673 | ||
|
|
43714047be | ||
|
|
5601927dc8 | ||
|
|
a16c5b0537 | ||
|
|
1eceb5f7fe | ||
|
|
09a7fad6ba | ||
|
|
02af7ee71c | ||
|
|
240c3ec081 | ||
|
|
51b823ae79 | ||
|
|
ac337955e7 | ||
|
|
b288462a1c | ||
|
|
82823aad1e | ||
|
|
64176fe6d7 | ||
|
|
6d344c74a8 | ||
|
|
a515f53731 | ||
|
|
f0527c94bc | ||
|
|
c40a9d9003 | ||
|
|
8ebad95adf | ||
|
|
169040c073 | ||
|
|
834a994af9 | ||
|
|
0a12e905c9 | ||
|
|
72ba89be63 | ||
|
|
9b7f53bdda | ||
|
|
4c1f36f9eb | ||
|
|
99586984bf | ||
|
|
e0bede0547 | ||
|
|
9f12db3d94 | ||
|
|
93c32edbb5 |
@@ -1,2 +0,0 @@
|
|||||||
read: .aider.conventions.md
|
|
||||||
auto-commits: false
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Agent context — Mathias workspace
|
|
||||||
|
|
||||||
<!-- Canonical root context for all AI coding agents.
|
|
||||||
Lives at: ~/dev/.context/AGENT.md
|
|
||||||
Applies to every project under ~/dev/ unless overridden.
|
|
||||||
|
|
||||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
|
||||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
|
||||||
|
|
||||||
## Who I am
|
|
||||||
|
|
||||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
|
||||||
I build software, research emerging tech, and deliver consulting engagements
|
|
||||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
|
||||||
and climate/sustainability tech.
|
|
||||||
|
|
||||||
## How I work with agents
|
|
||||||
|
|
||||||
- I think like a product manager — I care about *why* before *how*
|
|
||||||
- I want agents to be opinionated and push back, not just execute blindly
|
|
||||||
- I prefer concise responses; skip ceremony and get to the point
|
|
||||||
- When I say "build this", I mean production-quality with tests, not a demo
|
|
||||||
- Ask me before making irreversible changes or adding heavy dependencies
|
|
||||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
|
||||||
|
|
||||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
|
||||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
|
||||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
|
||||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
|
||||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
|
||||||
|
|
||||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
|
||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
|
||||||
deployable. Never create long-lived feature branches.
|
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
|
||||||
session. Do not leave agent branches open between sessions.
|
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
|
||||||
the reason in PROJECT.md.
|
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
|
||||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
|
||||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
|
||||||
actual last tag — stated versions in docs drift stale.
|
|
||||||
- **Push** main and the tag (CI is the gate).
|
|
||||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
|
||||||
the reusable patterns and the footguns that would bite anyone again, never
|
|
||||||
project status. See *Knowledge base — when to write* below.
|
|
||||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
|
||||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
|
||||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
|
||||||
source pointer.
|
|
||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
|
||||||
trail is auditable.
|
|
||||||
|
|
||||||
## Default stack
|
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| Language | Go | Python | TypeScript, Java, C |
|
|
||||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
|
||||||
| Build | Task (taskfile.dev) | Make | — |
|
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
|
||||||
| Logging | slog (structured) | — | — |
|
|
||||||
| Testing | Table-driven, testify | — | — |
|
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
|
||||||
|
|
||||||
## Code conventions
|
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
|
||||||
one logical change per commit, CI is the quality gate
|
|
||||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
|
||||||
passing `task check` locally first
|
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
|
||||||
|
|
||||||
## Secret handling (every harness, every command)
|
|
||||||
|
|
||||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
|
||||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
|
||||||
searchable forever, and clearing it means rotating the key. So:
|
|
||||||
|
|
||||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
|
||||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
|
||||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
|
||||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
|
||||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
|
||||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
|
||||||
in a command's argv (it lands in the tool call and the transcript).
|
|
||||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
|
||||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
|
||||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
|
||||||
the secret; do not forward a raw key over ssh argv/stdout.
|
|
||||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
|
||||||
don't bury it.
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
Three machines on Tailscale:
|
|
||||||
|
|
||||||
| Machine | Role | Key specs |
|
|
||||||
|---------|------|-----------|
|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
|
||||||
- **Networking**: Tailscale mesh
|
|
||||||
|
|
||||||
## Project landscape
|
|
||||||
|
|
||||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
|
||||||
|
|
||||||
Organized in thematic folders:
|
|
||||||
|
|
||||||
| Folder | Focus | Count |
|
|
||||||
|--------|-------|-------|
|
|
||||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
|
||||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
|
||||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
|
||||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
|
||||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
|
||||||
|
|
||||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|
||||||
|
|
||||||
### Key active projects
|
|
||||||
|
|
||||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
|
||||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
|
||||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
|
||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
|
||||||
|
|
||||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
|
||||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
|
||||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
|
||||||
reference material — query it actively, not just when explicitly told.**
|
|
||||||
|
|
||||||
### When to query (treat as a reflex)
|
|
||||||
|
|
||||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
|
||||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
|
||||||
- **When debugging** — search for the error string, the stack frame, the affected
|
|
||||||
service. Past you may have already paid this tax.
|
|
||||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
|
||||||
was tried and rejected, or what the integration footguns are.
|
|
||||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
|
||||||
"decision" to find prior reasoning before re-deriving it.
|
|
||||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
|
||||||
documented?" The brain often has it.
|
|
||||||
|
|
||||||
### When to write
|
|
||||||
|
|
||||||
After you discover something that **future-you would forget** and that **isn't
|
|
||||||
recoverable from the code, git log, or PR description alone**:
|
|
||||||
|
|
||||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
|
||||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
|
||||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
|
||||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
|
||||||
|
|
||||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
|
||||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
|
||||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|
||||||
|
|
||||||
### How to access (per harness)
|
|
||||||
|
|
||||||
| Harness | Query | Write |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
|
||||||
berget URL (see knowledge entry on namespace mismatches).
|
|
||||||
|
|
||||||
### Quick reflex checks
|
|
||||||
|
|
||||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
|
||||||
|
|
||||||
- "I think the issue might be..."
|
|
||||||
- "Let me try X and see..."
|
|
||||||
- "I'll just write a script to..."
|
|
||||||
- "This is probably a new bug..."
|
|
||||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
|
||||||
|
|
||||||
## Client work rules
|
|
||||||
|
|
||||||
When working on a project tagged with a client name:
|
|
||||||
1. Never send code, data, or context to cloud APIs — use local models only
|
|
||||||
2. Never reference other client projects or their data
|
|
||||||
3. Keep all artifacts within the client's git org / directory
|
|
||||||
4. Treat everything as confidential unless told otherwise
|
|
||||||
|
|
||||||
## Harness-agnostic principles
|
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
|
||||||
|
|
||||||
## How context propagates
|
|
||||||
|
|
||||||
Canonical sources of truth:
|
|
||||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
|
||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
|
||||||
derived together. Push.
|
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
|
||||||
message telling you to stage the regenerated files.
|
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
|
||||||
unconditionally on every host, every harness.
|
|
||||||
|
|
||||||
## Engineering Skills
|
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
|
||||||
|
|
||||||
| Task type | Load |
|
|
||||||
|-----------|------|
|
|
||||||
| Any feature or bug fix | `tdd` |
|
|
||||||
| Refactor or design | `clean-code` or `solid` |
|
|
||||||
| Debug | `problem-analysis` |
|
|
||||||
| Review code or PRs | `code-review` |
|
|
||||||
| Frame a problem before coding | `problem-analysis` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: gitea-mcp
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
|
||||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
|
||||||
- No feature branches, no PRs for solo/agent work.
|
|
||||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
|
||||||
`agent/<description>` branch and merge within the same session.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## Knowledge base access
|
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task in this project, regardless of harness.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. Commit directly to main. Run `task check` before every push. Never create
|
|
||||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current state — v0.2.5 (2026-05-17)
|
|
||||||
|
|
||||||
All v0.2 work is complete and deployed. No active sprint.
|
|
||||||
|
|
||||||
### What shipped
|
|
||||||
|
|
||||||
| Tag | PR | Tools / fixes |
|
|
||||||
|-----|----|---------------|
|
|
||||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
|
||||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
|
||||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
|
||||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
|
||||||
|
|
||||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
|
||||||
|
|
||||||
### Next up
|
|
||||||
|
|
||||||
1. **`hyperguild new-project` v1** — primary next target.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
|
||||||
|
|
||||||
2. **Issue #19** — end-to-end mirror flow verification.
|
|
||||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
+91
-22
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -78,6 +94,26 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -88,7 +124,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -97,7 +133,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -135,10 +176,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -234,10 +275,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
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.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -249,15 +291,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -268,7 +324,20 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
@@ -293,7 +362,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
-389
@@ -1,389 +0,0 @@
|
|||||||
# Cursor rules — auto-generated
|
|
||||||
# Do not edit. Run: task context:sync
|
|
||||||
|
|
||||||
# Agent context — Mathias workspace
|
|
||||||
|
|
||||||
<!-- Canonical root context for all AI coding agents.
|
|
||||||
Lives at: ~/dev/.context/AGENT.md
|
|
||||||
Applies to every project under ~/dev/ unless overridden.
|
|
||||||
|
|
||||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
|
||||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
|
||||||
|
|
||||||
## Who I am
|
|
||||||
|
|
||||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
|
||||||
I build software, research emerging tech, and deliver consulting engagements
|
|
||||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
|
||||||
and climate/sustainability tech.
|
|
||||||
|
|
||||||
## How I work with agents
|
|
||||||
|
|
||||||
- I think like a product manager — I care about *why* before *how*
|
|
||||||
- I want agents to be opinionated and push back, not just execute blindly
|
|
||||||
- I prefer concise responses; skip ceremony and get to the point
|
|
||||||
- When I say "build this", I mean production-quality with tests, not a demo
|
|
||||||
- Ask me before making irreversible changes or adding heavy dependencies
|
|
||||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
|
||||||
|
|
||||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
|
||||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
|
||||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
|
||||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
|
||||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
|
||||||
|
|
||||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
|
||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
|
||||||
deployable. Never create long-lived feature branches.
|
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
|
||||||
session. Do not leave agent branches open between sessions.
|
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
|
||||||
the reason in PROJECT.md.
|
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
|
||||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
|
||||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
|
||||||
actual last tag — stated versions in docs drift stale.
|
|
||||||
- **Push** main and the tag (CI is the gate).
|
|
||||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
|
||||||
the reusable patterns and the footguns that would bite anyone again, never
|
|
||||||
project status. See *Knowledge base — when to write* below.
|
|
||||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
|
||||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
|
||||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
|
||||||
source pointer.
|
|
||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
|
||||||
trail is auditable.
|
|
||||||
|
|
||||||
## Default stack
|
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| Language | Go | Python | TypeScript, Java, C |
|
|
||||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
|
||||||
| Build | Task (taskfile.dev) | Make | — |
|
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
|
||||||
| Logging | slog (structured) | — | — |
|
|
||||||
| Testing | Table-driven, testify | — | — |
|
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
|
||||||
|
|
||||||
## Code conventions
|
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
|
||||||
one logical change per commit, CI is the quality gate
|
|
||||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
|
||||||
passing `task check` locally first
|
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
|
||||||
|
|
||||||
## Secret handling (every harness, every command)
|
|
||||||
|
|
||||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
|
||||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
|
||||||
searchable forever, and clearing it means rotating the key. So:
|
|
||||||
|
|
||||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
|
||||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
|
||||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
|
||||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
|
||||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
|
||||||
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
|
||||||
in a command's argv (it lands in the tool call and the transcript).
|
|
||||||
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
|
||||||
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
|
||||||
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
|
||||||
the secret; do not forward a raw key over ssh argv/stdout.
|
|
||||||
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
|
||||||
don't bury it.
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
Three machines on Tailscale:
|
|
||||||
|
|
||||||
| Machine | Role | Key specs |
|
|
||||||
|---------|------|-----------|
|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
|
||||||
- **Networking**: Tailscale mesh
|
|
||||||
|
|
||||||
## Project landscape
|
|
||||||
|
|
||||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
|
||||||
|
|
||||||
Organized in thematic folders:
|
|
||||||
|
|
||||||
| Folder | Focus | Count |
|
|
||||||
|--------|-------|-------|
|
|
||||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
|
||||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
|
||||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
|
||||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
|
||||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
|
||||||
|
|
||||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|
||||||
|
|
||||||
### Key active projects
|
|
||||||
|
|
||||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
|
||||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
|
||||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
|
||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
|
||||||
|
|
||||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
|
||||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
|
||||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
|
||||||
reference material — query it actively, not just when explicitly told.**
|
|
||||||
|
|
||||||
### When to query (treat as a reflex)
|
|
||||||
|
|
||||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
|
||||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
|
||||||
- **When debugging** — search for the error string, the stack frame, the affected
|
|
||||||
service. Past you may have already paid this tax.
|
|
||||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
|
||||||
was tried and rejected, or what the integration footguns are.
|
|
||||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
|
||||||
"decision" to find prior reasoning before re-deriving it.
|
|
||||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
|
||||||
documented?" The brain often has it.
|
|
||||||
|
|
||||||
### When to write
|
|
||||||
|
|
||||||
After you discover something that **future-you would forget** and that **isn't
|
|
||||||
recoverable from the code, git log, or PR description alone**:
|
|
||||||
|
|
||||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
|
||||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
|
||||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
|
||||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
|
||||||
|
|
||||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
|
||||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
|
||||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|
||||||
|
|
||||||
### How to access (per harness)
|
|
||||||
|
|
||||||
| Harness | Query | Write |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
|
||||||
berget URL (see knowledge entry on namespace mismatches).
|
|
||||||
|
|
||||||
### Quick reflex checks
|
|
||||||
|
|
||||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
|
||||||
|
|
||||||
- "I think the issue might be..."
|
|
||||||
- "Let me try X and see..."
|
|
||||||
- "I'll just write a script to..."
|
|
||||||
- "This is probably a new bug..."
|
|
||||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
|
||||||
|
|
||||||
## Client work rules
|
|
||||||
|
|
||||||
When working on a project tagged with a client name:
|
|
||||||
1. Never send code, data, or context to cloud APIs — use local models only
|
|
||||||
2. Never reference other client projects or their data
|
|
||||||
3. Keep all artifacts within the client's git org / directory
|
|
||||||
4. Treat everything as confidential unless told otherwise
|
|
||||||
|
|
||||||
## Harness-agnostic principles
|
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
|
||||||
|
|
||||||
## How context propagates
|
|
||||||
|
|
||||||
Canonical sources of truth:
|
|
||||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
|
||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
|
||||||
derived together. Push.
|
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
|
||||||
message telling you to stage the regenerated files.
|
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
|
||||||
unconditionally on every host, every harness.
|
|
||||||
|
|
||||||
## Engineering Skills
|
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
|
||||||
|
|
||||||
| Task type | Load |
|
|
||||||
|-----------|------|
|
|
||||||
| Any feature or bug fix | `tdd` |
|
|
||||||
| Refactor or design | `clean-code` or `solid` |
|
|
||||||
| Debug | `problem-analysis` |
|
|
||||||
| Review code or PRs | `code-review` |
|
|
||||||
| Frame a problem before coding | `problem-analysis` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: gitea-mcp
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
|
||||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
|
||||||
- No feature branches, no PRs for solo/agent work.
|
|
||||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
|
||||||
`agent/<description>` branch and merge within the same session.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## Knowledge base access
|
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task in this project, regardless of harness.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. Commit directly to main. Run `task check` before every push. Never create
|
|
||||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current state — v0.2.5 (2026-05-17)
|
|
||||||
|
|
||||||
All v0.2 work is complete and deployed. No active sprint.
|
|
||||||
|
|
||||||
### What shipped
|
|
||||||
|
|
||||||
| Tag | PR | Tools / fixes |
|
|
||||||
|-----|----|---------------|
|
|
||||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
|
||||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
|
||||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
|
||||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
|
||||||
|
|
||||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
|
||||||
|
|
||||||
### Next up
|
|
||||||
|
|
||||||
1. **`hyperguild new-project` v1** — primary next target.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
|
||||||
|
|
||||||
2. **Issue #19** — end-to-end mirror flow verification.
|
|
||||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
|
||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build an OCI image with buildctl and push it to the local registry.
|
||||||
|
#
|
||||||
|
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
|
||||||
|
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
|
||||||
|
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
|
||||||
|
# edit here, re-sync, and the gate will tell you which repos still disagree.
|
||||||
|
#
|
||||||
|
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
|
||||||
|
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
|
||||||
|
# verifications, and three separate rediscoveries of the same fix in the two
|
||||||
|
# repo templates and the gitea-ci skill. The differences between the copies were
|
||||||
|
# incidental -- a label here, a build-arg there -- never intentional. Those are
|
||||||
|
# inputs now, not forks of the implementation.
|
||||||
|
#
|
||||||
|
# Why buildctl and not buildah: the gitea-runner pod runs with
|
||||||
|
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
|
||||||
|
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
|
||||||
|
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
|
||||||
|
# client -- it hands the build to the host's buildkitd over a socket, so the
|
||||||
|
# privileged layer work happens on the host and this pod's securityContext is
|
||||||
|
# not in the picture. buildah is absent from the runner image as of baked-v14
|
||||||
|
# and its absence is asserted at pod start (infra#308).
|
||||||
|
#
|
||||||
|
# Inputs, all via environment:
|
||||||
|
#
|
||||||
|
# IMAGE (required) image name, e.g. cobalt-dingo
|
||||||
|
# REGISTRY default localhost:5000
|
||||||
|
# SHA_TAG default `git rev-parse --short HEAD`
|
||||||
|
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
|
||||||
|
# PUSH_LATEST default 1; set 0 to skip the :latest tag
|
||||||
|
# CONTEXT default . -- build context directory
|
||||||
|
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
|
||||||
|
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
|
||||||
|
# TARGET optional, multi-stage build target (--opt target=)
|
||||||
|
# BUILD_ARGS optional, space-separated K=V pairs
|
||||||
|
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
|
||||||
|
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
|
||||||
|
# writes the secret to PATH itself. A secret VALUE must
|
||||||
|
# never be passed here: it would land in this script's
|
||||||
|
# argv and therefore in the CI log.
|
||||||
|
# EXTRA_LABELS optional, space-separated K=V pairs
|
||||||
|
# GIT_SHA optional, stamped as org.opencontainers.image.revision
|
||||||
|
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
|
||||||
|
#
|
||||||
|
# Note on shell style below: every conditional is a full `if` block, never
|
||||||
|
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
|
||||||
|
# test is false, which is a real bug this estate has already shipped once.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${IMAGE:-}" ]; then
|
||||||
|
echo "build-image: IMAGE is required" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-localhost:5000}"
|
||||||
|
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
|
||||||
|
CONTEXT="${CONTEXT:-.}"
|
||||||
|
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
|
||||||
|
PUSH_LATEST="${PUSH_LATEST:-1}"
|
||||||
|
VERSION_TAG="${VERSION_TAG:-}"
|
||||||
|
|
||||||
|
if [ -z "${SHA_TAG:-}" ]; then
|
||||||
|
SHA_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
|
||||||
|
|
||||||
|
TAR_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$TAR_FILE"' EXIT
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--frontend dockerfile.v0
|
||||||
|
--local "context=${CONTEXT}"
|
||||||
|
--local "dockerfile=${DOCKERFILE_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -n "${DOCKERFILE_NAME:-}" ]; then
|
||||||
|
args+=(--opt "filename=${DOCKERFILE_NAME}")
|
||||||
|
fi
|
||||||
|
if [ -n "${TARGET:-}" ]; then
|
||||||
|
args+=(--opt "target=${TARGET}")
|
||||||
|
fi
|
||||||
|
for spec in ${BUILD_SECRETS:-}; do
|
||||||
|
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
|
||||||
|
args+=(--secret "${spec}")
|
||||||
|
done
|
||||||
|
if [ -n "${GIT_SHA:-}" ]; then
|
||||||
|
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
|
||||||
|
fi
|
||||||
|
if [ -n "${IMAGE_SOURCE:-}" ]; then
|
||||||
|
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
|
||||||
|
fi
|
||||||
|
for kv in ${BUILD_ARGS:-}; do
|
||||||
|
args+=(--opt "build-arg:${kv}")
|
||||||
|
done
|
||||||
|
for kv in ${EXTRA_LABELS:-}; do
|
||||||
|
args+=(--opt "label:${kv}")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "build-image: building ${REF}"
|
||||||
|
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
|
||||||
|
|
||||||
|
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
|
||||||
|
# the OCI archive up -- once per tag, from the same archive, so every tag points
|
||||||
|
# at bit-identical content.
|
||||||
|
push_tag() {
|
||||||
|
echo "build-image: pushing ${1}"
|
||||||
|
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_tag "$REF"
|
||||||
|
if [ "$PUSH_LATEST" = "1" ]; then
|
||||||
|
push_tag "${REGISTRY}/${IMAGE}:latest"
|
||||||
|
fi
|
||||||
|
if [ -n "$VERSION_TAG" ]; then
|
||||||
|
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ build-image: ${REF} pushed"
|
||||||
+43
-27
@@ -4,6 +4,10 @@ name: CD
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
# Docs-only pushes don't change the image — skip the build/deploy roll.
|
||||||
|
# (Only applies to branch pushes; tag pushes always trigger.)
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea-mcp
|
IMAGE: gitea-mcp
|
||||||
@@ -54,37 +58,42 @@ jobs:
|
|||||||
REF="${{ github.ref }}"
|
REF="${{ github.ref }}"
|
||||||
if [[ "$REF" == refs/tags/v* ]]; then
|
if [[ "$REF" == refs/tags/v* ]]; then
|
||||||
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
# The ldflags version stamp: git tag on a v* build, else short
|
||||||
|
# sha. Computed in shell rather than as a fallback expression in
|
||||||
|
# YAML, so it cannot depend on how the runner treats an empty step
|
||||||
|
# output -- a wrong VERSION would be baked into the binary silently.
|
||||||
|
#
|
||||||
|
# NOTE: no expression syntax in this comment, deliberately. act
|
||||||
|
# interpolates a run: block INCLUDING its comments, so prose
|
||||||
|
# containing that syntax is evaluated as an expression -- it failed
|
||||||
|
# with "Unavailable context: a", the step wrote no outputs, and the
|
||||||
|
# deploy job committed an empty image tag that broke Flux
|
||||||
|
# estate-wide (infra 51ceb7c).
|
||||||
|
echo "version=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push to local registry
|
- name: Build and push to local registry
|
||||||
run: |
|
# infra#309: implementation lives in .gitea/build-image.sh, a
|
||||||
REGISTRY="localhost:5000"
|
# byte-identical copy of mathias/infra's ci-templates/build-image.sh.
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
# Do not edit it here -- edit the canonical file, run `task ci:sync`,
|
||||||
buildah build \
|
# and infra's drift gate names any repo still carrying an old copy.
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
env:
|
||||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
|
||||||
-t ${REF} \
|
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
|
||||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
GIT_SHA: ${{ github.sha }}
|
||||||
.
|
IMAGE_SOURCE: ${{ github.repositoryUrl }}
|
||||||
buildah push --tls-verify=false ${REF}
|
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
|
||||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
run: bash .gitea/build-image.sh
|
||||||
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
|
|
||||||
buildah push --tls-verify=false \
|
|
||||||
${REF} \
|
|
||||||
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
|
||||||
echo "✓ Image pushed to ${REF}"
|
|
||||||
|
|
||||||
- name: Smoke test
|
# No smoke test here. The old one ran `sudo k3s ctr run` against the host's
|
||||||
run: |
|
# containerd: written for the Arch host runner, impossible in the
|
||||||
REGISTRY="localhost:5000"
|
# containerized runner (infra#233) which has no sudo, no ctr and no
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
# containerd socket -- it failed with `sudo: command not found` the moment
|
||||||
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
|
# infra#241's buildctl migration let the build reach it. The deploy job's
|
||||||
sudo k3s ctr images pull --plain-http ${REF}
|
# `Verify rollout` + `Confirm pod running new image` cover the same ground
|
||||||
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
|
# more strongly, which is why tapir dropped its smoke test too (infra#241).
|
||||||
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
|
|
||||||
echo "$OUTPUT" | grep -q "gitea-mcp" \
|
|
||||||
&& echo "✓ Smoke test passed" \
|
|
||||||
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
|
|
||||||
|
|
||||||
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
|
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
@@ -100,6 +109,13 @@ jobs:
|
|||||||
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# infra#311: fail on an empty tag BEFORE patching anything. The
|
||||||
|
# grep verification further down CANNOT catch it -- with IMAGE_TAG
|
||||||
|
# empty its pattern matches the very corruption it exists to reject.
|
||||||
|
# The result is `image: <repo>:` in the manifest, which is invalid
|
||||||
|
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
|
||||||
|
# stops reconciling, not just this one. That happened on 2026-08-25.
|
||||||
|
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||||
|
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
||||||
|
|||||||
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -73,6 +89,26 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -83,7 +119,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -92,7 +128,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -130,10 +171,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -229,10 +270,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
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.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -244,15 +286,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -263,7 +319,20 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
@@ -288,7 +357,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|||||||
+7
-4
@@ -1,20 +1,23 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going
|
# Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
|
||||||
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||||
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||||
# the module proxy + sumdb.
|
# the module proxy + sumdb.
|
||||||
RUN apk add --no-cache git && \
|
RUN apk add --no-cache git && \
|
||||||
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
|
||||||
ENV GOPRIVATE=gitea.d-ma.be
|
ENV GOPRIVATE=git.d-ma.be
|
||||||
ENV GOPROXY=direct
|
ENV GOPROXY=direct
|
||||||
ENV GOSUMDB=off
|
ENV GOSUMDB=off
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
|
# Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
|
||||||
|
# "dev" for a plain `docker build` (#47).
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
COPY --from=build /out/gitea-mcp /gitea-mcp
|
COPY --from=build /out/gitea-mcp /gitea-mcp
|
||||||
|
|||||||
+2
-4
@@ -27,12 +27,12 @@ tasks:
|
|||||||
echo "✓ context sync: skipped in CI"
|
echo "✓ context sync: skipped in CI"
|
||||||
else
|
else
|
||||||
bash scripts/context-sync.sh
|
bash scripts/context-sync.sh
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
if [ -n "$drift" ]; then
|
if [ -n "$drift" ]; then
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
echo "$drift" >&2
|
echo "$drift" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
||||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -58,5 +58,3 @@ tasks:
|
|||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
context:sync:agents:
|
context:sync:agents:
|
||||||
cmds: [bash scripts/context-sync.sh agents]
|
cmds: [bash scripts/context-sync.sh agents]
|
||||||
context:sync:cursor:
|
|
||||||
cmds: [bash scripts/context-sync.sh cursor]
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Command gitea-admin-mcp is a LOCAL-ONLY stdio MCP server exposing exactly
|
||||||
|
// one tool: mint_service_account_token.
|
||||||
|
//
|
||||||
|
// This exists because dispatch#67 fix #2 hit a hard wall: Gitea's own
|
||||||
|
// token-creation and admin-user endpoints reject token auth outright — an
|
||||||
|
// existing API token cannot create another token, or a new account, by
|
||||||
|
// design (a leaked token being usable to escalate would defeat the point of
|
||||||
|
// scoping it). The only way through is real basic auth as an actual admin,
|
||||||
|
// which a sandboxed agent session cannot safely hold. This binary is meant
|
||||||
|
// to be spawned by a LOCAL Claude Code session (or run by hand) on a machine
|
||||||
|
// with real, interactive 1Password access — configured as a stdio MCP
|
||||||
|
// server in .mcp.json, or invoked directly.
|
||||||
|
//
|
||||||
|
// Deliberately NOT part of cmd/gitea-mcp (the shared HTTP server): the
|
||||||
|
// capability here (reset a target account's password, mint it a token) is
|
||||||
|
// far too dangerous to ever expose over the network, even behind OIDC.
|
||||||
|
// internal/localtools is a separate package specifically so it can never be
|
||||||
|
// wired into that server's RegisterAll by accident.
|
||||||
|
//
|
||||||
|
// Required env (fetch admin credentials just-in-time, e.g. via
|
||||||
|
// `op run --env-file ~/.op-env -- gitea-admin-mcp`):
|
||||||
|
//
|
||||||
|
// GITEA_BASE_URL e.g. https://git.d-ma.be
|
||||||
|
// GITEA_ADMIN_USERNAME a real site-admin account's username
|
||||||
|
// GITEA_ADMIN_PASSWORD that account's real password
|
||||||
|
// OP_VAULT 1Password vault to store minted tokens in
|
||||||
|
// (default: AgentSecrets, per the estate's agent-governed
|
||||||
|
// ops-secrets policy)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Logs to stderr, never stdout — stdout is the MCP JSON-RPC channel, and
|
||||||
|
// a stray log line there would corrupt the protocol stream.
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||||
|
|
||||||
|
baseURL := os.Getenv("GITEA_BASE_URL")
|
||||||
|
adminUser := os.Getenv("GITEA_ADMIN_USERNAME")
|
||||||
|
adminPass := os.Getenv("GITEA_ADMIN_PASSWORD")
|
||||||
|
if baseURL == "" || adminUser == "" || adminPass == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "GITEA_BASE_URL, GITEA_ADMIN_USERNAME, and GITEA_ADMIN_PASSWORD are all required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := adminbootstrap.Bootstrapper{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
AdminUsername: adminUser,
|
||||||
|
AdminPassword: adminPass,
|
||||||
|
Secrets: localtools.OpSecretStore{Vault: envOr("OP_VAULT", "AgentSecrets")},
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := registry.New()
|
||||||
|
reg.Register(localtools.NewMintServiceAccountToken(b))
|
||||||
|
|
||||||
|
if err := mcp.ServeStdio(context.Background(), reg, os.Stdin, os.Stdout); err != nil {
|
||||||
|
logger.Error("serve stdio", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
saCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
|
saTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
|
||||||
|
)
|
||||||
|
|
||||||
|
// k8sBearerRT adds this pod's ServiceAccount bearer to every request. k3s serves
|
||||||
|
// its OIDC discovery/JWKS over the cluster CA and requires an AUTHENTICATED
|
||||||
|
// request (anonymous is 401), so the JWKS fetch must carry a token — ADR-0011.
|
||||||
|
type k8sBearerRT struct {
|
||||||
|
token string
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b k8sBearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+b.token)
|
||||||
|
return b.base.RoundTrip(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sOIDCClient builds an HTTP client that can reach the in-cluster k8s OIDC
|
||||||
|
// discovery + JWKS: it trusts the cluster CA and carries this pod's SA bearer.
|
||||||
|
// Returns an error (not running in a pod, files unreadable) so the caller can
|
||||||
|
// skip the k8s issuer without disabling other auth.
|
||||||
|
func k8sOIDCClient() (*http.Client, error) {
|
||||||
|
ca, err := os.ReadFile(saCAFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read cluster CA: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(ca) {
|
||||||
|
return nil, fmt.Errorf("parse cluster CA: no certs in %s", saCAFile)
|
||||||
|
}
|
||||||
|
tok, err := os.ReadFile(saTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read SA token: %w", err)
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: k8sBearerRT{
|
||||||
|
token: strings.TrimSpace(string(tok)),
|
||||||
|
base: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+49
-10
@@ -9,17 +9,25 @@ import (
|
|||||||
|
|
||||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
|
||||||
|
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
|
||||||
|
// "0.1.0" — it now tells the truth instead of a stale literal).
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
|
||||||
|
// the same structured handler as the rest of the server.
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -29,7 +37,36 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
|
||||||
|
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
|
||||||
|
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
|
||||||
|
// can't be built (not in a pod) or its discovery is unreachable at startup,
|
||||||
|
// it is dropped so existing Authentik/static auth is never taken down.
|
||||||
|
var issuers []chassisauth.IssuerConfig
|
||||||
|
if cfg.DexIssuerURL != "" {
|
||||||
|
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
|
||||||
|
}
|
||||||
|
if cfg.K8sIssuerURL != "" {
|
||||||
|
if client, cerr := k8sOIDCClient(); cerr != nil {
|
||||||
|
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
|
||||||
|
} else {
|
||||||
|
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||||
|
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
|
||||||
|
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
|
||||||
|
// startup. Don't let it take down Authentik JWT auth — retry without it.
|
||||||
|
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
|
||||||
|
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
|
||||||
|
for _, ic := range issuers {
|
||||||
|
if ic.IssuerURL != cfg.K8sIssuerURL {
|
||||||
|
pub = append(pub, ic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
|
||||||
|
}
|
||||||
if jwtInitErr != nil {
|
if jwtInitErr != nil {
|
||||||
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||||
}
|
}
|
||||||
@@ -55,8 +92,10 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
|
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
auth.CallerMiddleware(mcpSrv),
|
auth.CallerMiddleware(logger, mcpSrv),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
@@ -66,7 +105,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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", version)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
logger.Error("server stopped", "err", err)
|
logger.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# gitea-admin-mcp
|
||||||
|
|
||||||
|
A separate, local-only stdio MCP server. One tool: `mint_service_account_token`.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
dispatch#67 fix #2 needed a read-only CI credential for a dedicated service
|
||||||
|
account, and hit a hard wall: Gitea's own `POST /users/{u}/tokens` and
|
||||||
|
`POST /admin/users` both reject token auth outright —
|
||||||
|
|
||||||
|
{"message":"auth required"}
|
||||||
|
{"message":"token does not have at least one of required scope(s), required=[write:admin], ..."}
|
||||||
|
|
||||||
|
— by design: an existing API token cannot mint another token, or create an
|
||||||
|
account, no matter its scope. The only way through either endpoint is real
|
||||||
|
basic auth as an actual site admin. A sandboxed agent session has no
|
||||||
|
interactive password and shouldn't be handed one — that's broader exposure
|
||||||
|
than the job needs. This binary is meant to run where real admin credentials
|
||||||
|
already exist just-in-time: your own machine, via `op run`.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
`mint_service_account_token` does the whole bootstrap in one call:
|
||||||
|
|
||||||
|
1. Creates the named account if it doesn't exist (admin basic auth), or
|
||||||
|
resets its password if it does. **This is why `confirm_service_account`
|
||||||
|
is required and checked first** — the tool unconditionally (re)sets
|
||||||
|
whatever password the target account has, which is correct for a
|
||||||
|
dedicated bot account and would be destructive pointed at a real person's
|
||||||
|
login by mistake.
|
||||||
|
2. Mints a scoped token for that account, authenticating as the account
|
||||||
|
itself (Gitea requires this specifically — an admin cannot mint a token
|
||||||
|
on another account's behalf).
|
||||||
|
3. Grants the account read-only collaborator access on any named
|
||||||
|
`owner/repo` targets (admin action, least privilege).
|
||||||
|
4. Stores the token in 1Password via `op item create`, value passed on
|
||||||
|
stdin — never as a literal argv element, matching the estate's own
|
||||||
|
secret-handling rule. Returns only the `op://` reference, never the
|
||||||
|
token value itself, to the caller.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Deliberately **not** wired into `cmd/gitea-mcp` (the shared HTTP server) —
|
||||||
|
this capability is too dangerous to expose over the network, even behind
|
||||||
|
OIDC. `internal/localtools` is a separate package specifically so it can
|
||||||
|
never be wired into that server's `RegisterAll` by accident.
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```
|
||||||
|
go build -o gitea-admin-mcp ./cmd/gitea-admin-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure as a local stdio MCP server (Claude Code `.mcp.json`, run where
|
||||||
|
your real `op` session lives):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea-admin": {
|
||||||
|
"command": "op",
|
||||||
|
"args": ["run", "--env-file", "~/.op-env", "--", "/path/to/gitea-admin-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required env (see `~/.op-env`):
|
||||||
|
|
||||||
|
| Var | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `GITEA_BASE_URL` | e.g. `https://git.d-ma.be` |
|
||||||
|
| `GITEA_ADMIN_USERNAME` | a real site-admin account |
|
||||||
|
| `GITEA_ADMIN_PASSWORD` | that account's real password |
|
||||||
|
| `OP_VAULT` | 1Password vault for minted tokens (default `AgentSecrets`, per the estate's agent-governed ops-secrets policy) |
|
||||||
|
|
||||||
|
## Example call
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "dispatch-bot",
|
||||||
|
"email": "dispatch@d-ma.be",
|
||||||
|
"scopes": ["read:repository", "read:issue"],
|
||||||
|
"token_name": "dispatch-ci-readonly",
|
||||||
|
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
||||||
|
"grant_read_repos": ["mathias/cobalt-dingo"],
|
||||||
|
"confirm_service_account": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Result carries `SecretRef` (e.g. `op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential`)
|
||||||
|
and `GrantedRepos` — never the token or either password.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# How gitea-mcp works
|
||||||
|
|
||||||
|
`gitea-mcp` is a custom **MCP (Model Context Protocol) front door for Gitea** — a
|
||||||
|
small Go HTTP service that exposes ~60 Gitea operations as MCP tools over
|
||||||
|
Streamable HTTP, so a claude.ai connector (or any MCP client) can drive the
|
||||||
|
homelab's Gitea (`https://git.d-ma.be`) with proper auth, an owner allowlist,
|
||||||
|
caller attribution, and defensive input handling.
|
||||||
|
|
||||||
|
It is **not** a generic Gitea proxy. It is an opinionated, allowlisted, single-
|
||||||
|
tenant front door built on the shared **`mcp-chassis`** auth library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Request lifecycle
|
||||||
|
|
||||||
|
Everything is wired in `cmd/gitea-mcp/main.go` on a plain `http.ServeMux`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /mcp
|
||||||
|
→ OriginAllowlist (internal/mcp/origin.go) browser Origin gate
|
||||||
|
→ BearerMiddleware (mcp-chassis auth) authN: static token OR JWT
|
||||||
|
→ CallerMiddleware (internal/auth/caller.go) extract caller identity
|
||||||
|
→ MCP server (internal/mcp/server.go) JSON-RPC dispatch → tools
|
||||||
|
|
||||||
|
GET /healthz unauthenticated health JSON
|
||||||
|
GET /.well-known/oauth-protected-resource RFC 9728 metadata (when Dex set)
|
||||||
|
```
|
||||||
|
|
||||||
|
The middleware order is deliberate: Origin first (cheap reject), then authN,
|
||||||
|
then identity extraction, then the MCP handler. A tool call is a JSON-RPC
|
||||||
|
`tools/call` that the registry dispatches to a `Tool` handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication (`mcp-chassis` `BearerMiddleware`)
|
||||||
|
|
||||||
|
`Authorization: Bearer <token>` is checked with this precedence (`auth/bearer.go`
|
||||||
|
in `mcp-chassis`):
|
||||||
|
|
||||||
|
1. **Static bearer** — constant-time compare against `GITEA_MCP_STATIC_TOKEN`.
|
||||||
|
Wins immediately, never emits a `WWW-Authenticate` challenge. This is the
|
||||||
|
service-to-service path (repo `.mcp.json` files carrying `GITEA_MCP_TOKEN`).
|
||||||
|
2. **JWT validation** — against a **list of trusted OIDC issuers**
|
||||||
|
(`NewMultiJWTValidator`, ADR-0011):
|
||||||
|
- **Authentik** (`DEX_ISSUER_URL` + `MCP_AUDIENCE=claude-ai`) — the claude.ai
|
||||||
|
web connector after its OAuth handshake.
|
||||||
|
- **k3s cluster OIDC** (`K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp`) — lets
|
||||||
|
in-cluster pods authenticate with audience-bound projected **ServiceAccount
|
||||||
|
tokens** instead of a static bearer. The k8s issuer is additive and
|
||||||
|
best-effort: if the in-cluster OIDC can't be reached at startup it is
|
||||||
|
dropped so Authentik auth is never taken down (see `cmd/gitea-mcp/main.go`
|
||||||
|
and `cmd/gitea-mcp/k8soidc.go`).
|
||||||
|
3. Otherwise **401**. If `MCP_RESOURCE_URL` + `DEX_ISSUER_URL` are set, the 401
|
||||||
|
carries a `WWW-Authenticate: … resource_metadata=…` header (RFC 9728) so
|
||||||
|
claude.ai's OAuth discovery can find `/.well-known/oauth-protected-resource`.
|
||||||
|
|
||||||
|
A JWKS/issuer outage yields **503** (`ErrUnavailable`), distinct from a
|
||||||
|
present-but-invalid token's **401**, so a transient IdP blip is retried rather
|
||||||
|
than treated as a hard auth failure.
|
||||||
|
|
||||||
|
### The k8s SA-token wrinkle (ADR-0011)
|
||||||
|
|
||||||
|
k3s serves its OIDC discovery/JWKS over the **cluster CA** and requires an
|
||||||
|
**authenticated** request (anonymous → 401). So `k8sOIDCClient()` builds an HTTP
|
||||||
|
client that trusts `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` and
|
||||||
|
carries the pod's own SA bearer, passed to the chassis via
|
||||||
|
`IssuerConfig.HTTPClient`. Proven live: a real `aud=gitea-mcp` SA token → the
|
||||||
|
running server → HTTP 200.
|
||||||
|
|
||||||
|
### Origin allowlist
|
||||||
|
|
||||||
|
`OriginAllowlist` (`internal/mcp/origin.go`) rejects any request whose `Origin`
|
||||||
|
header is not in `GITEA_MCP_ORIGIN_ALLOWLIST` (`https://claude.ai`,
|
||||||
|
`https://api.anthropic.com`). An **empty** Origin (server-side callers) is
|
||||||
|
allowed — Origin is a browser-only header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Authorization, identity & attribution
|
||||||
|
|
||||||
|
- **Owner allowlist** (`internal/allowlist`) — tools only operate on owners in
|
||||||
|
`GITEA_MCP_ALLOWED_OWNERS` (default `mathias`). A call for any other owner is
|
||||||
|
rejected before it reaches Gitea.
|
||||||
|
- **Caller identity** (`internal/auth/caller.go`) — the authenticated username is
|
||||||
|
read from reverse-proxy identity headers, `X-Auth-Request-User` (the verified
|
||||||
|
OIDC identity, authoritative) preferred over `X-Forwarded-User`. Conflicts are
|
||||||
|
logged, not silently resolved.
|
||||||
|
- **Identity footer** (`internal/identity/footer.go`) — mutating tools that write
|
||||||
|
a body (issue/PR comments, creates) append
|
||||||
|
`_Created via git-mcp on behalf of @<caller>_`, so actions taken through the
|
||||||
|
front door are attributable even though all upstream calls use one service PAT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Upstream Gitea access
|
||||||
|
|
||||||
|
`internal/gitea/Client` is a thin REST client over `GITEA_BASE_URL`
|
||||||
|
(`https://git.d-ma.be`). Every upstream call carries a **single service PAT** —
|
||||||
|
`GITEA_MCP_DEFAULT_TOKEN` — as `Authorization: token <PAT>` (`client.go`). So
|
||||||
|
gitea-mcp is a *front door*, not a credential pass-through: the caller's identity
|
||||||
|
is captured for attribution, but Gitea sees one service account. A 60s branch
|
||||||
|
cache and a 30s HTTP timeout round it out.
|
||||||
|
|
||||||
|
Defensive guard: paths with an empty owner/repo segment (`//`) are rejected
|
||||||
|
locally (`hasEmptySegment`) so callers get a typed validation error instead of
|
||||||
|
Gitea's opaque `/api/swagger` 404.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tools
|
||||||
|
|
||||||
|
Tools are registered in `internal/tools/registry.go` into a `registry.Registry`;
|
||||||
|
each implements the `Tool` interface (name, JSON schema, handler). ~60 tools,
|
||||||
|
by area:
|
||||||
|
|
||||||
|
| Area | Tools |
|
||||||
|
|------|-------|
|
||||||
|
| Repos | list, get, search, status, create, delete, update, tree, topics_update, mirror_push |
|
||||||
|
| Files | file_read, file_write_branch, file_delete, dir_list |
|
||||||
|
| Branches | branch_list, branch_delete, branch_protection_get |
|
||||||
|
| Issues | list, get, create, edit, close, reopen, comment, label, list_comments |
|
||||||
|
| PRs | list, get, create, comment, merge, files_diff |
|
||||||
|
| Labels / Releases / Tags | label_list, release_create, tag_create |
|
||||||
|
| Workflows (Actions) | run_list, run_status, run_trigger |
|
||||||
|
| Search / Templates | code_search, create_project_from_template |
|
||||||
|
| Composite | tbd_ship (trunk-based ship helper) |
|
||||||
|
|
||||||
|
Shared tool-layer hygiene (`internal/tools/tool.go`):
|
||||||
|
- **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so
|
||||||
|
a tool works whichever spelling a caller sends (an explicit canonical wins).
|
||||||
|
- **Required-identifier validation** — an empty `repo`/`name` is rejected at the
|
||||||
|
tool layer (avoids the bare-404 leak); `owner` is covered by the allowlist.
|
||||||
|
- **Page-size cap** — limits are clamped to 50.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Configuration (env)
|
||||||
|
|
||||||
|
| Var | Purpose | Example |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `GITEA_MCP_PORT` | listen port | `8080` |
|
||||||
|
| `GITEA_BASE_URL` | upstream Gitea | `https://git.d-ma.be` |
|
||||||
|
| `GITEA_MCP_DEFAULT_TOKEN` | **upstream** service PAT (all Gitea calls) | *(secret)* |
|
||||||
|
| `GITEA_MCP_STATIC_TOKEN` | static bearer for service-to-service auth | *(secret)* |
|
||||||
|
| `GITEA_MCP_ALLOWED_OWNERS` | owner allowlist | `mathias` |
|
||||||
|
| `GITEA_MCP_ORIGIN_ALLOWLIST` | permitted browser Origins | `https://claude.ai,…` |
|
||||||
|
| `DEX_ISSUER_URL` / `MCP_AUDIENCE` | Authentik issuer + audience | `…/o/claude-ai/`, `claude-ai` |
|
||||||
|
| `K8S_ISSUER_URL` / `K8S_MCP_AUDIENCE` | k8s OIDC issuer + audience (SA tokens) | `https://kubernetes.default.svc.cluster.local`, `gitea-mcp` |
|
||||||
|
| `MCP_RESOURCE_URL` | this server's public URL for `.well-known` metadata | `https://git-mcp.d-ma.be` |
|
||||||
|
|
||||||
|
Secrets come from k8s Secrets (`gitea-mcp-secrets`, `gitea-mcp-static-token`);
|
||||||
|
non-secret values are inlined in the Deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Health & observability
|
||||||
|
|
||||||
|
- `GET /healthz` → `{"ok":true,"jwt":{"status":"disabled|enabled|degraded","last_error":"…"}}`.
|
||||||
|
`degraded` = a JWT issuer was configured but init failed (static-token auth
|
||||||
|
still works) — distinguishes "IdP unreachable" from "JWT not configured".
|
||||||
|
- Every auth rejection is audit-logged (`mcp-chassis` `deny`) with the reason,
|
||||||
|
client IP, token *type*, and a truncated SHA-256 **fingerprint** — never the
|
||||||
|
raw token, so nothing secret lands in logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment & CI/CD
|
||||||
|
|
||||||
|
- **Runs in** the `gitea-mcp` k3s namespace (`git.d-ma.be/mathias/infra`
|
||||||
|
`k3s/apps/gitea-mcp/`): a Deployment on `localhost:5000/gitea-mcp:<sha>`, an
|
||||||
|
ExternalSecret for tokens, and a **default-deny NetworkPolicy** + an
|
||||||
|
`allow-ingress-nginx` rule (this namespace is the P6.1 netpol pilot — the only
|
||||||
|
ingress allowed is from `ingress-nginx`).
|
||||||
|
- **Public endpoint** `https://git-mcp.d-ma.be` (NPM → ingress-nginx → svc:8080).
|
||||||
|
- **Pipeline** (`.gitea/workflows/cd.yml`, self-hosted koala runner): quality gate
|
||||||
|
(test/vet) → `buildah` image → `localhost:5000` → smoke test → the deploy job
|
||||||
|
clones infra over SSH, `sed`s the image tag in `k3s/apps/gitea-mcp/deployment.yaml`,
|
||||||
|
commits, and triggers a **Flux** reconcile. Push to `main` ⇒ new image ⇒ rolled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Operational notes
|
||||||
|
|
||||||
|
- **Rollback** = revert the image tag (or the env change) in the infra Deployment;
|
||||||
|
Flux rolls back. Auth changes are additive, so enabling/disabling the k8s issuer
|
||||||
|
never affects the Authentik/static paths.
|
||||||
|
- **NetworkPolicy kill switch** — delete the `gitea-mcp` NetworkPolicies to
|
||||||
|
restore open ingress if the pilot ever locks something out.
|
||||||
|
- **claude.ai connector flakiness** — the claude.ai→gitea-mcp MCP path
|
||||||
|
intermittently hits a Cloudflare "you have been blocked" page (the error names
|
||||||
|
`anthropic.com`). On the Claude Code CLI, prefer the direct Gitea REST API for
|
||||||
|
reliability; the MCP connector remains the claude.ai-web path. (brain:
|
||||||
|
`prefer-rest-api-skills-over-mcp-on-cli-cloudflare-waf`.)
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Auth library: `git.d-ma.be/mathias/mcp-chassis` (`auth` package) — shared
|
||||||
|
`BearerMiddleware` + multi-issuer `JWTValidator` + RFC 9728 handler.
|
||||||
|
- Multi-issuer / SA-token design: infra `docs/decisions/ADR-0011-service-to-service-auth.md`.
|
||||||
|
- Source map: `cmd/gitea-mcp/{main,healthz,k8soidc}.go`,
|
||||||
|
`internal/{mcp,auth,gitea,tools,allowlist,identity,registry,config}`.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
module gitea.d-ma.be/mathias/gitea-mcp
|
module git.d-ma.be/mathias/gitea-mcp
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
git.d-ma.be/mathias/mcp-chassis v0.5.0
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
||||||
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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=
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// Package adminbootstrap mints a scoped Gitea access token for a dedicated
|
||||||
|
// service/bot account, end to end, in one auditable call.
|
||||||
|
//
|
||||||
|
// dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
|
||||||
|
// Gitea's own token-creation and admin-user endpoints reject token auth
|
||||||
|
// outright (POST /users/{u}/tokens and POST /admin/users both return
|
||||||
|
// "auth required" / a required-scope error for an existing token, by
|
||||||
|
// design — a leaked token being usable to mint further tokens would be a
|
||||||
|
// privilege-escalation footgun). Neither endpoint has a token-scoped
|
||||||
|
// workaround. The only way through is real basic auth as an actual admin.
|
||||||
|
//
|
||||||
|
// That is exactly what a sandboxed agent session cannot safely have — no
|
||||||
|
// interactive password, and handing one to a session would be broader
|
||||||
|
// exposure than the job needs. This package is meant to run LOCALLY, where
|
||||||
|
// real admin credentials exist just-in-time (1Password, `op run`), as a
|
||||||
|
// small stdio MCP tool (see cmd/gitea-admin-mcp) rather than ad hoc curl.
|
||||||
|
package adminbootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPDoer is the one method this package needs from *http.Client — narrow
|
||||||
|
// on purpose, so tests inject a fake transport instead of hitting a network.
|
||||||
|
type HTTPDoer interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretStore persists the minted token somewhere durable and returns a
|
||||||
|
// reference safe to log or display — never the value itself. The real
|
||||||
|
// implementation (cmd/gitea-admin-mcp) shells out to the local `op` CLI;
|
||||||
|
// tests use an in-memory fake.
|
||||||
|
type SecretStore interface {
|
||||||
|
Store(ctx context.Context, name, value string) (ref string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoRef names a repo to grant the new account read-only collaborator
|
||||||
|
// access to — least privilege: a service account should see only the repos
|
||||||
|
// it actually operates against, not every private repo on the instance.
|
||||||
|
type RepoRef struct {
|
||||||
|
Owner string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request describes one bootstrap. ConfirmServiceAccount is required and
|
||||||
|
// checked first: this package (re)sets whatever password Username already
|
||||||
|
// has, which is correct and safe for a dedicated bot account and dangerous
|
||||||
|
// for anything else.
|
||||||
|
type Request struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
FullName string
|
||||||
|
Scopes []string
|
||||||
|
TokenName string
|
||||||
|
SecretName string
|
||||||
|
GrantReadRepos []RepoRef
|
||||||
|
ConfirmServiceAccount bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result reports what happened. SecretRef, never the token value, is what a
|
||||||
|
// caller (or an MCP tool's text response) should ever surface.
|
||||||
|
type Result struct {
|
||||||
|
Username string
|
||||||
|
Created bool // false if the account already existed (password was reset, not created)
|
||||||
|
SecretRef string
|
||||||
|
GrantedRepos []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrapper holds the real admin credentials and where to reach Gitea —
|
||||||
|
// constructed once per invocation, never persisted.
|
||||||
|
type Bootstrapper struct {
|
||||||
|
BaseURL string
|
||||||
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
|
HTTP HTTPDoer // nil defaults to http.DefaultClient
|
||||||
|
Secrets SecretStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bootstrapper) httpClient() HTTPDoer {
|
||||||
|
if b.HTTP != nil {
|
||||||
|
return b.HTTP
|
||||||
|
}
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run creates (or resets the password of) Username, mints it a scoped
|
||||||
|
// token, grants it read-only access to GrantReadRepos, stores the token via
|
||||||
|
// Secrets, and returns without ever exposing the token value or either
|
||||||
|
// password to the caller.
|
||||||
|
func (b Bootstrapper) Run(ctx context.Context, req Request) (Result, error) {
|
||||||
|
if !req.ConfirmServiceAccount {
|
||||||
|
return Result{}, fmt.Errorf("refusing: ConfirmServiceAccount must be true — this resets %q's password unconditionally, which is only safe for a dedicated service/bot account", req.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := b.userExists(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("check existing account: %w", err)
|
||||||
|
}
|
||||||
|
created = !created // userExists returns true if found; Result.Created means "we created it"
|
||||||
|
|
||||||
|
botPassword, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("generate account password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if created {
|
||||||
|
if err := b.createUser(ctx, req, botPassword); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("create account: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := b.resetPassword(ctx, req.Username, botPassword); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("reset existing account's password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := b.mintToken(ctx, req.Username, botPassword, req.TokenName, req.Scopes)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("mint token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var granted []string
|
||||||
|
for _, r := range req.GrantReadRepos {
|
||||||
|
if err := b.grantReadAccess(ctx, r, req.Username); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("grant read access on %s/%s: %w", r.Owner, r.Name, err)
|
||||||
|
}
|
||||||
|
granted = append(granted, r.Owner+"/"+r.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := b.Secrets.Store(ctx, req.SecretName, token)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("store token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Username: req.Username,
|
||||||
|
Created: created,
|
||||||
|
SecretRef: ref,
|
||||||
|
GrantedRepos: granted,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPassword() (string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bootstrapper) userExists(ctx context.Context, username string) (bool, error) {
|
||||||
|
resp, err := b.doAdmin(ctx, http.MethodGet, "/api/v1/users/"+username, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
return true, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unexpected status %d checking account existence", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bootstrapper) createUser(ctx context.Context, req Request, password string) error {
|
||||||
|
body := map[string]any{
|
||||||
|
"username": req.Username,
|
||||||
|
"email": req.Email,
|
||||||
|
"password": password,
|
||||||
|
"must_change_password": false,
|
||||||
|
}
|
||||||
|
if req.FullName != "" {
|
||||||
|
body["full_name"] = req.FullName
|
||||||
|
}
|
||||||
|
resp, err := b.doAdmin(ctx, http.MethodPost, "/api/v1/admin/users", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return statusErr(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bootstrapper) resetPassword(ctx context.Context, username, password string) error {
|
||||||
|
resp, err := b.doAdmin(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, map[string]any{
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return statusErr(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintToken authenticates as the SERVICE ACCOUNT itself (not the admin) —
|
||||||
|
// Gitea's token-creation endpoint requires basic auth as the token's owner,
|
||||||
|
// and an admin's own credentials cannot mint on another account's behalf.
|
||||||
|
func (b Bootstrapper) mintToken(ctx context.Context, username, password, tokenName string, scopes []string) (string, error) {
|
||||||
|
raw, err := json.Marshal(map[string]any{"name": tokenName, "scopes": scopes})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
strings.TrimRight(b.BaseURL, "/")+"/api/v1/users/"+username+"/tokens", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(username, password)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := b.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", statusErr(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return "", fmt.Errorf("decode token response: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.SHA1 == "" {
|
||||||
|
return "", fmt.Errorf("token response carried no sha1 value")
|
||||||
|
}
|
||||||
|
return parsed.SHA1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantReadAccess is an admin action (adding a collaborator to a repo the
|
||||||
|
// admin owns), so it uses admin auth, not the new account's own.
|
||||||
|
func (b Bootstrapper) grantReadAccess(ctx context.Context, r RepoRef, username string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", r.Owner, r.Name, username)
|
||||||
|
resp, err := b.doAdmin(ctx, http.MethodPut, path, map[string]any{"permission": "read"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return statusErr(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bootstrapper) doAdmin(ctx context.Context, method, path string, body map[string]any) (*http.Response, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(b.BaseURL, "/")+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(b.AdminUsername, b.AdminPassword)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
return b.httpClient().Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusErr(resp *http.Response) error {
|
||||||
|
msg, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("gitea returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package adminbootstrap_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
|
||||||
|
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
|
||||||
|
// reject token auth outright, confirmed live against git.d-ma.be — an
|
||||||
|
// existing token cannot mint another token, by design). A sandboxed agent
|
||||||
|
// session has no interactive password, so this package is meant to run
|
||||||
|
// locally, where a real admin password is available just-in-time (1Password,
|
||||||
|
// `op run`), and to do the whole bootstrap in one auditable call rather than
|
||||||
|
// leaving it as ad hoc curl.
|
||||||
|
//
|
||||||
|
// Design choice, made explicit rather than silently assumed: this tool
|
||||||
|
// ALWAYS (re)sets the target account's password via admin basic auth before
|
||||||
|
// minting, whether the account is new or already existed. That is safe and
|
||||||
|
// expected for a dedicated service/bot account (its password is a mechanism
|
||||||
|
// to reach the token endpoint, never a human login credential) and would be
|
||||||
|
// dangerous pointed at a real person's account by mistake -- which is why
|
||||||
|
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
|
||||||
|
|
||||||
|
type call struct {
|
||||||
|
method, path string
|
||||||
|
body map[string]any
|
||||||
|
authUser string // basic-auth username used for this call, "" if token/none
|
||||||
|
authPass string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTransport struct {
|
||||||
|
t *testing.T
|
||||||
|
responses map[string]fakeResponse // key: "METHOD path"
|
||||||
|
calls []call
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeResponse struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
var body map[string]any
|
||||||
|
if req.Body != nil {
|
||||||
|
raw, _ := io.ReadAll(req.Body)
|
||||||
|
if len(raw) > 0 {
|
||||||
|
require.NoError(f.t, json.Unmarshal(raw, &body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, pass, _ := req.BasicAuth()
|
||||||
|
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
|
||||||
|
|
||||||
|
key := req.Method + " " + req.URL.Path
|
||||||
|
r, ok := f.responses[key]
|
||||||
|
if !ok {
|
||||||
|
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: r.status,
|
||||||
|
Body: io.NopCloser(strings.NewReader(r.body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m map[string]fakeResponse) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSecretStore records what would have been written to 1Password, without
|
||||||
|
// ever touching it -- and, critically, asserts the token value itself never
|
||||||
|
// leaks into anything but this one call.
|
||||||
|
type fakeSecretStore struct {
|
||||||
|
stored map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
|
||||||
|
if f.stored == nil {
|
||||||
|
f.stored = map[string]string{}
|
||||||
|
}
|
||||||
|
f.stored[name] = value
|
||||||
|
return "op://AgentSecrets/" + name + "/token", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
|
||||||
|
ft := &fakeTransport{t: t, responses: responses}
|
||||||
|
fs := &fakeSecretStore{}
|
||||||
|
return adminbootstrap.Bootstrapper{
|
||||||
|
BaseURL: "http://fake.gitea",
|
||||||
|
AdminUsername: "mathias",
|
||||||
|
AdminPassword: "admin-pass-not-a-real-secret",
|
||||||
|
HTTP: &http.Client{Transport: ft},
|
||||||
|
Secrets: fs,
|
||||||
|
}, ft, fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
|
||||||
|
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
|
||||||
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||||
|
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
|
||||||
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||||
|
Username: "dispatch-bot",
|
||||||
|
Email: "dispatch@d-ma.be",
|
||||||
|
Scopes: []string{"read:repository", "read:issue"},
|
||||||
|
TokenName: "dispatch-ci-readonly",
|
||||||
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||||
|
ConfirmServiceAccount: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Created)
|
||||||
|
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
|
||||||
|
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
|
||||||
|
|
||||||
|
// The account-creation call must use the ADMIN's basic auth, never the
|
||||||
|
// fresh account's own (unknown at that point) password.
|
||||||
|
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
|
||||||
|
assert.Equal(t, "mathias", create.authUser)
|
||||||
|
assert.Equal(t, "dispatch-bot", create.body["username"])
|
||||||
|
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
|
||||||
|
assert.Equal(t, false, create.body["must_change_password"])
|
||||||
|
|
||||||
|
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
|
||||||
|
// requires this specifically (an admin token cannot mint on someone
|
||||||
|
// else's behalf) -- with the password this package generated and set,
|
||||||
|
// never the admin's own password.
|
||||||
|
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
|
||||||
|
assert.Equal(t, "dispatch-bot", mint.authUser)
|
||||||
|
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
|
||||||
|
assert.NotEmpty(t, mint.authPass)
|
||||||
|
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
|
||||||
|
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
|
||||||
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
||||||
|
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
|
||||||
|
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
|
||||||
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||||
|
Username: "dispatch-bot",
|
||||||
|
Email: "dispatch@d-ma.be",
|
||||||
|
Scopes: []string{"read:repository"},
|
||||||
|
TokenName: "dispatch-ci-readonly",
|
||||||
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||||
|
ConfirmServiceAccount: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
|
||||||
|
|
||||||
|
// Must PATCH (reset password), never POST (create) for an existing account.
|
||||||
|
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
|
||||||
|
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
|
||||||
|
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
|
||||||
|
assert.NotEmpty(t, patch.body["password"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
|
||||||
|
// Guard against pointing this at a real person's account by accident --
|
||||||
|
// this tool resets whatever password the target account has.
|
||||||
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
|
||||||
|
|
||||||
|
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||||
|
Username: "mathias",
|
||||||
|
Email: "mthbqv@gmail.com",
|
||||||
|
Scopes: []string{"read:repository"},
|
||||||
|
TokenName: "whatever",
|
||||||
|
SecretName: "whatever",
|
||||||
|
// ConfirmServiceAccount left false/unset
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
|
||||||
|
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
|
||||||
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
||||||
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||||
|
"POST /api/v1/admin/users": {status: 201, body: `{}`},
|
||||||
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
|
||||||
|
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||||
|
Username: "dispatch-bot",
|
||||||
|
Email: "dispatch@d-ma.be",
|
||||||
|
Scopes: []string{"read:repository"},
|
||||||
|
TokenName: "dispatch-ci-readonly",
|
||||||
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
||||||
|
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
|
||||||
|
ConfirmServiceAccount: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
|
||||||
|
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
|
||||||
|
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
|
||||||
|
assert.Equal(t, "read", grant.body["permission"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
|
||||||
|
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
|
||||||
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
||||||
|
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
||||||
|
Username: "dispatch-bot",
|
||||||
|
Email: "dispatch@d-ma.be",
|
||||||
|
Scopes: []string{"read:repository"},
|
||||||
|
TokenName: "x",
|
||||||
|
SecretName: "x",
|
||||||
|
ConfirmServiceAccount: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCall(t *testing.T, calls []call, method, path string) call {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range calls {
|
||||||
|
if c.method == method && c.path == path {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
|
||||||
|
return call{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCalls(calls []call, method, path string) []call {
|
||||||
|
var out []call
|
||||||
|
for _, c := range calls {
|
||||||
|
if c.method == method && c.path == path {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package allowlist
|
package allowlist
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
type Allowlist struct {
|
type Allowlist struct {
|
||||||
owners map[string]struct{}
|
owners map[string]struct{}
|
||||||
@@ -14,10 +19,17 @@ func New(owners []string) *Allowlist {
|
|||||||
return &Allowlist{owners: m}
|
return &Allowlist{owners: m}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Allowlist) Check(owner string) error {
|
// Check gates owner access to the static list — except for a caller
|
||||||
|
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
|
||||||
|
// access Gitea's own permission model already gates more precisely than a
|
||||||
|
// coarse owner name list ever could.
|
||||||
|
func (a *Allowlist) Check(ctx context.Context, owner string) error {
|
||||||
if owner == "" {
|
if owner == "" {
|
||||||
return fmt.Errorf("owner required")
|
return fmt.Errorf("owner required")
|
||||||
}
|
}
|
||||||
|
if _, ok := gitea.TokenFromContext(ctx); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if _, ok := a.owners[owner]; !ok {
|
if _, ok := a.owners[owner]; !ok {
|
||||||
return fmt.Errorf("owner %q not in allowlist", owner)
|
return fmt.Errorf("owner %q not in allowlist", owner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
package allowlist_test
|
package allowlist_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAllowlistCheck(t *testing.T) {
|
func TestAllowlistCheck(t *testing.T) {
|
||||||
a := allowlist.New([]string{"mathias", "acme"})
|
a := allowlist.New([]string{"mathias", "acme"})
|
||||||
assert.NoError(t, a.Check("mathias"))
|
ctx := context.Background()
|
||||||
assert.NoError(t, a.Check("acme"))
|
assert.NoError(t, a.Check(ctx, "mathias"))
|
||||||
assert.Error(t, a.Check("evil"))
|
assert.NoError(t, a.Check(ctx, "acme"))
|
||||||
assert.Error(t, a.Check(""))
|
assert.Error(t, a.Check(ctx, "evil"))
|
||||||
|
assert.Error(t, a.Check(ctx, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
|
||||||
|
// is gated by Gitea's own permission model, not the MCP's static owner list —
|
||||||
|
// otherwise a legitimate second user could never touch their own repos.
|
||||||
|
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
|
||||||
|
assert.NoError(t, a.Check(ctx, "someone-else"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty owner is a structural input error, not an authz question — still
|
||||||
|
// rejected even on the pass-through path.
|
||||||
|
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
|
||||||
|
assert.Error(t, a.Check(ctx, ""))
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -2,22 +2,48 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey struct{}
|
type ctxKey struct{}
|
||||||
|
|
||||||
func CallerMiddleware(next http.Handler) http.Handler {
|
// CallerMiddleware extracts the authenticated username from the reverse-proxy
|
||||||
|
// identity headers and stashes it in the request context for Caller().
|
||||||
|
//
|
||||||
|
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
|
||||||
|
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
|
||||||
|
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
|
||||||
|
// convention some setups populate instead; it is used only as a fallback when
|
||||||
|
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
|
||||||
|
// verified X-Auth-Request-User still wins and we log a warning so the
|
||||||
|
// misconfiguration is visible rather than silently resolved (#10).
|
||||||
|
//
|
||||||
|
// logger may be nil, in which case the conflict warning is skipped.
|
||||||
|
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user := r.Header.Get("X-Auth-Request-User")
|
authUser := r.Header.Get("X-Auth-Request-User")
|
||||||
|
fwdUser := r.Header.Get("X-Forwarded-User")
|
||||||
|
|
||||||
|
user := authUser
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = r.Header.Get("X-Forwarded-User")
|
user = fwdUser
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
|
||||||
|
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
|
||||||
|
"x_auth_request_user", authUser,
|
||||||
|
"x_forwarded_user", fwdUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withCaller(ctx context.Context, user string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, user)
|
||||||
|
}
|
||||||
|
|
||||||
func Caller(ctx context.Context) string {
|
func Caller(ctx context.Context) string {
|
||||||
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -1,26 +1,80 @@
|
|||||||
package auth_test
|
package auth_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCallerFromContext(t *testing.T) {
|
func discardLogger() *slog.Logger {
|
||||||
called := false
|
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
|
||||||
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
}
|
||||||
called = true
|
|
||||||
assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
|
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
|
||||||
|
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
|
||||||
|
// absent.
|
||||||
|
func TestCallerHeaderPrecedence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authReq string
|
||||||
|
forwarded string
|
||||||
|
wantCaller string
|
||||||
|
}{
|
||||||
|
{"auth-request only", "mathiasbq", "", "mathiasbq"},
|
||||||
|
{"forwarded fallback", "", "fwduser", "fwduser"},
|
||||||
|
{"both present, same", "same", "same", "same"},
|
||||||
|
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
|
||||||
|
{"neither", "", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
got = auth.Caller(r.Context())
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
req.Header.Set("X-Auth-Request-User", "mathiasbq")
|
if tc.authReq != "" {
|
||||||
rr := httptest.NewRecorder()
|
req.Header.Set("X-Auth-Request-User", tc.authReq)
|
||||||
h.ServeHTTP(rr, req)
|
}
|
||||||
assert.True(t, called)
|
if tc.forwarded != "" {
|
||||||
|
req.Header.Set("X-Forwarded-User", tc.forwarded)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
assert.Equal(t, tc.wantCaller, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When both headers are present and disagree, a warning is logged so the proxy
|
||||||
|
// misconfiguration is visible rather than silent.
|
||||||
|
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
|
||||||
|
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
req.Header.Set("X-Auth-Request-User", "authuser")
|
||||||
|
req.Header.Set("X-Forwarded-User", "fwduser")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
assert.Contains(t, logged, "conflicting")
|
||||||
|
assert.Contains(t, logged, "authuser")
|
||||||
|
assert.Contains(t, logged, "fwduser")
|
||||||
|
|
||||||
|
// No warning when they agree.
|
||||||
|
buf.Reset()
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
req2.Header.Set("X-Auth-Request-User", "same")
|
||||||
|
req2.Header.Set("X-Forwarded-User", "same")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req2)
|
||||||
|
assert.Empty(t, buf.String(), "no warning expected when headers agree")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
|
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenValidator asks the upstream service who a bearer token belongs to.
|
||||||
|
type TokenValidator interface {
|
||||||
|
ValidateToken(ctx context.Context, token string) (username string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
|
||||||
|
// if the request's bearer token validates directly against Gitea, it's used
|
||||||
|
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
|
||||||
|
// the server's shared default token. Any other bearer (static token, JWT, or
|
||||||
|
// none) falls through to fallback unchanged — this only adds a capability, it
|
||||||
|
// never removes the existing auth paths.
|
||||||
|
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authz := r.Header.Get("Authorization")
|
||||||
|
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
||||||
|
if hasBearer && token != "" {
|
||||||
|
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
||||||
|
ctx := withCaller(r.Context(), login)
|
||||||
|
ctx = gitea.WithToken(ctx, token)
|
||||||
|
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeValidator struct {
|
||||||
|
login string
|
||||||
|
ok bool
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
|
||||||
|
f.calls++
|
||||||
|
return f.login, f.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
|
||||||
|
validator := &fakeValidator{login: "alice", ok: true}
|
||||||
|
var gotCaller string
|
||||||
|
var gotToken string
|
||||||
|
var gotOK bool
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
gotCaller = auth.Caller(r.Context())
|
||||||
|
gotToken, gotOK = gitea.TokenFromContext(r.Context())
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("fallback should not be called for a valid PAT")
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer alices-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.Equal(t, "alice", gotCaller)
|
||||||
|
assert.True(t, gotOK)
|
||||||
|
assert.Equal(t, "alices-pat", gotToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
|
||||||
|
validator := &fakeValidator{ok: false}
|
||||||
|
fallbackCalled := false
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("onValid should not be called for an invalid token")
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
fallbackCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.True(t, fallbackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
|
||||||
|
validator := &fakeValidator{ok: true, login: "alice"}
|
||||||
|
fallbackCalled := false
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("onValid should not be called with no Authorization header")
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
fallbackCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.True(t, fallbackCalled)
|
||||||
|
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ type Config struct {
|
|||||||
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
|
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
|
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
|
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
|
||||||
|
K8sIssuerURL string // K8S_ISSUER_URL, in-cluster OIDC issuer for ServiceAccount-token auth (ADR-0011 D1); empty disables
|
||||||
|
K8sAudience string // K8S_MCP_AUDIENCE, required audience claim for k8s SA tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -28,6 +30,8 @@ func Load() (Config, error) {
|
|||||||
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
|
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
|
||||||
MCPAudience: os.Getenv("MCP_AUDIENCE"),
|
MCPAudience: os.Getenv("MCP_AUDIENCE"),
|
||||||
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
|
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
|
||||||
|
K8sIssuerURL: os.Getenv("K8S_ISSUER_URL"),
|
||||||
|
K8sAudience: os.Getenv("K8S_MCP_AUDIENCE"),
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +20,21 @@ type Client struct {
|
|||||||
branchCache *expirable.LRU[string, string]
|
branchCache *expirable.LRU[string, string]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ctxTokenKey struct{}
|
||||||
|
|
||||||
|
// WithToken overrides the token used for upstream Gitea calls made with the
|
||||||
|
// returned context, taking precedence over the Client's configured default
|
||||||
|
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||||
|
func WithToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxTokenKey{}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenFromContext returns the token set by WithToken, if any.
|
||||||
|
func TokenFromContext(ctx context.Context) (string, bool) {
|
||||||
|
v, ok := ctx.Value(ctxTokenKey{}).(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
@@ -28,6 +44,23 @@ func NewClient(baseURL, token string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
|
||||||
|
// using that token, not the client's configured default token) and returns
|
||||||
|
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||||
|
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
|
||||||
|
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
|
||||||
|
if err != nil || status != http.StatusOK {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var user struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return user.Login, true
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||||
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||||
key := owner + "/" + name
|
key := owner + "/" + name
|
||||||
@@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+token)
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
@@ -135,6 +171,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+token)
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
|||||||
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
ctx := gitea.WithToken(context.Background(), "caller-token")
|
||||||
|
_, status, err := c.GetJSON(ctx, "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, status)
|
||||||
|
assert.Equal(t, "token caller-token", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
login, ok := c.ValidateToken(context.Background(), "candidate-token")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "alice", login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
login, ok := c.ValidateToken(context.Background(), "bad-token")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Empty(t, login)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRetryOnPostNotRetried(t *testing.T) {
|
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||||
var attempts int32
|
var attempts int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
+135
-15
@@ -1,10 +1,13 @@
|
|||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CodeSearchHit struct {
|
type CodeSearchHit struct {
|
||||||
@@ -14,30 +17,147 @@ type CodeSearchHit struct {
|
|||||||
Score float64 `json:"score,omitempty"`
|
Score float64 `json:"score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type codeSearchEnvelope struct {
|
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
|
||||||
Data []CodeSearchHit `json:"data"`
|
// and scan, so one call against a huge repo can't run indefinitely.
|
||||||
OK bool `json:"ok"`
|
const codeSearchMaxFiles = 2000
|
||||||
|
|
||||||
|
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
|
||||||
|
// before any fetch) — almost certainly binary/vendor/generated content, not
|
||||||
|
// worth the cost of fetching just to reject.
|
||||||
|
const codeSearchMaxFileSize = 512 * 1024
|
||||||
|
|
||||||
|
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
|
||||||
|
// obviously-binary content by extension, checked against the tree listing.
|
||||||
|
var codeSearchBinaryExts = map[string]bool{
|
||||||
|
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
|
||||||
|
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
|
||||||
|
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
|
||||||
|
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
|
||||||
|
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
|
||||||
|
".pyc": true, ".o": true, ".a": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
|
||||||
|
// has no code-content-search endpoint — confirmed against a live 1.25.5
|
||||||
|
// instance's swagger spec (only /repos/search, /repos/issues/search,
|
||||||
|
// /topics/search etc exist). The web UI's OWN code search falls back to
|
||||||
|
// server-side `git grep` because no Repository Indexer is enabled on that
|
||||||
|
// instance, and that fallback is an HTML-only route, not JSON API. So this
|
||||||
|
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
|
||||||
|
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
|
||||||
|
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
|
||||||
|
// user-supplied input) — against file contents.
|
||||||
|
//
|
||||||
|
// Pagination is over the FULL sorted result set, recomputed on every call —
|
||||||
|
// there is no server-side index to page through incrementally, so requesting
|
||||||
|
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
|
||||||
|
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
|
||||||
|
// fix, and is an infra decision, not something gitea-mcp controls.
|
||||||
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
||||||
|
if q == "" {
|
||||||
|
return nil, fmt.Errorf("q is required: %w", ErrValidation)
|
||||||
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
|
|
||||||
owner, repo, url.QueryEscape(q), page, limit)
|
r, err := c.GetRepo(ctx, owner, repo)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||||
}
|
}
|
||||||
if err := MapStatus(status, body); err != nil {
|
branch := r.DefaultBranch
|
||||||
return nil, err
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
}
|
}
|
||||||
var env codeSearchEnvelope
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
tree, err := c.GetTree(ctx, owner, repo, branch, true)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tree walk: %w", err)
|
||||||
}
|
}
|
||||||
return env.Data, nil
|
|
||||||
|
qLower := strings.ToLower(q)
|
||||||
|
all := make([]CodeSearchHit, 0)
|
||||||
|
scanned := 0
|
||||||
|
for _, e := range tree.Tree {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e.Type != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Size > codeSearchMaxFileSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scanned >= codeSearchMaxFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
scanned++
|
||||||
|
|
||||||
|
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
|
||||||
|
if ferr != nil {
|
||||||
|
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
|
||||||
|
}
|
||||||
|
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if derr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bytes.IndexByte(decoded, 0) >= 0 {
|
||||||
|
continue // binary content the extension filter missed
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(decoded)
|
||||||
|
contentLower := strings.ToLower(content)
|
||||||
|
count := strings.Count(contentLower, qLower)
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(contentLower, qLower)
|
||||||
|
all = append(all, CodeSearchHit{
|
||||||
|
Path: e.Path,
|
||||||
|
Snippet: codeSearchSnippet(content, idx, len(q)),
|
||||||
|
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
|
||||||
|
Score: float64(count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
if all[i].Score != all[j].Score {
|
||||||
|
return all[i].Score > all[j].Score
|
||||||
|
}
|
||||||
|
return all[i].Path < all[j].Path
|
||||||
|
})
|
||||||
|
|
||||||
|
start := (page - 1) * limit
|
||||||
|
if start >= len(all) {
|
||||||
|
return []CodeSearchHit{}, nil
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
return all[start:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeSearchSnippet returns a short window of text centered on a match,
|
||||||
|
// trimmed to a single line-ish window so results read like a grep hit rather
|
||||||
|
// than a content dump.
|
||||||
|
func codeSearchSnippet(content string, idx, matchLen int) string {
|
||||||
|
const window = 60
|
||||||
|
start := idx - window
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + matchLen + window
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
|
||||||
|
return strings.TrimSpace(snippet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,78 @@ package gitea_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSearchCode(t *testing.T) {
|
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
|
||||||
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
|
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
|
||||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
|
||||||
|
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
|
||||||
|
// UI's own code search falls back to server-side `git grep`, an HTML-only
|
||||||
|
// route, not JSON API). Records which paths were actually fetched, so tests
|
||||||
|
// can assert a file was SKIPPED (binary ext, oversized) without ever being
|
||||||
|
// read, not just absent from results.
|
||||||
|
type codeSearchFake struct {
|
||||||
|
files map[string]string // path -> content
|
||||||
|
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
|
||||||
|
fetched []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{
|
p := r.URL.Path
|
||||||
"data":[{
|
switch {
|
||||||
"path":"internal/gitea/code_search.go",
|
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
|
||||||
"snippet":"func (c *Client) SearchCode",
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
|
||||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
|
|
||||||
"score":2.5
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
|
||||||
}],
|
var entries []string
|
||||||
"ok":true
|
for path, content := range f.files {
|
||||||
}`))
|
size := int64(len(content))
|
||||||
}))
|
if s, ok := f.sizes[path]; ok {
|
||||||
|
size = s
|
||||||
|
}
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
|
||||||
|
f.fetched = append(f.fetched, path)
|
||||||
|
content, ok := f.files[path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_FindsMatchInTree(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
|
||||||
|
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
@@ -34,6 +81,92 @@ func TestSearchCode(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, hits, 1)
|
require.Len(t, hits, 1)
|
||||||
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
||||||
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
|
assert.Contains(t, hits[0].Snippet, "SearchCode")
|
||||||
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
|
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
|
||||||
|
assert.Equal(t, 1.0, hits[0].Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_CaseInsensitive(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"README.md": "# MyProject\n\nBuild instructions here.\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, hits, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"assets/logo.png": "SearchCode", // would match if fetched — must not be
|
||||||
|
"main.go": "package main\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits)
|
||||||
|
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
|
||||||
|
f := &codeSearchFake{
|
||||||
|
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
|
||||||
|
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits)
|
||||||
|
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"data.bin": "SearchCode\x00binary-marker",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_Pagination(t *testing.T) {
|
||||||
|
files := map[string]string{}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
|
||||||
|
}
|
||||||
|
f := &codeSearchFake{files: files}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, page1, 2)
|
||||||
|
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, page2, 2)
|
||||||
|
|
||||||
|
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
|
||||||
|
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
|
||||||
|
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
|
||||||
|
c := gitea.NewClient("http://unused", "tok")
|
||||||
|
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Issue struct {
|
|||||||
type Label struct {
|
type Label struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListLabels fetches all labels defined on a repo.
|
||||||
|
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var labels []Label
|
||||||
|
if err := json.Unmarshal(body, &labels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
|
||||||
|
// space with issues, per Gitea). This is additive per Gitea's own POST
|
||||||
|
// semantics — existing labels are left in place, no replace/delete needed.
|
||||||
|
// Returns the issue's full label set after the add.
|
||||||
|
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var labels []Label
|
||||||
|
if err := json.Unmarshal(body, &labels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListLabels(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, labels, 2)
|
||||||
|
assert.Equal(t, int64(1), labels[0].ID)
|
||||||
|
assert.Equal(t, "bug", labels[0].Name)
|
||||||
|
assert.Equal(t, "ee0701", labels[0].Color)
|
||||||
|
assert.Equal(t, "enhancement", labels[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLabels_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLabels_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddIssueLabels(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":3,"name":"priority","color":"00ff00"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
ids, ok := payload["labels"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, ids, 1)
|
||||||
|
assert.Equal(t, float64(3), ids[0])
|
||||||
|
|
||||||
|
require.Len(t, labels, 2)
|
||||||
|
assert.Equal(t, "bug", labels[0].Name)
|
||||||
|
assert.Equal(t, "priority", labels[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddIssueLabels_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ type PullRequest struct {
|
|||||||
Draft bool `json:"draft"`
|
Draft bool `json:"draft"`
|
||||||
Head struct {
|
Head struct {
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
} `json:"head"`
|
} `json:"head"`
|
||||||
Base struct {
|
Base struct {
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
} `json:"base"`
|
} `json:"base"`
|
||||||
|
Mergeable bool `json:"mergeable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePullRequestArgs struct {
|
type CreatePullRequestArgs struct {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package identity_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Package localtools holds MCP tools that must never be reachable from the
|
||||||
|
// shared HTTP gitea-mcp server (RegisterAll, cmd/gitea-mcp). Each tool here
|
||||||
|
// carries a capability too dangerous to expose network-side, even behind
|
||||||
|
// OIDC — mint_service_account_token resets whatever password its target
|
||||||
|
// account has. Only cmd/gitea-admin-mcp, a separate local-only stdio binary,
|
||||||
|
// imports this package.
|
||||||
|
package localtools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapper is the one method this tool needs — narrow so tests inject a
|
||||||
|
// fake instead of a real adminbootstrap.Bootstrapper.
|
||||||
|
type bootstrapper interface {
|
||||||
|
Run(ctx context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MintServiceAccountToken struct {
|
||||||
|
b bootstrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMintServiceAccountToken(b bootstrapper) *MintServiceAccountToken {
|
||||||
|
return &MintServiceAccountToken{b: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MintServiceAccountToken) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "mint_service_account_token",
|
||||||
|
Description: "Create (or reset the password of) a dedicated Gitea " +
|
||||||
|
"service/bot account, mint it a scoped access token, optionally " +
|
||||||
|
"grant it read-only collaborator access on named repos, and store " +
|
||||||
|
"the token via 1Password. Requires confirm_service_account=true: " +
|
||||||
|
"this resets whatever password the target account currently has, " +
|
||||||
|
"which is only safe for a dedicated bot account, never a real " +
|
||||||
|
"person's login.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"username":{"type":"string"},
|
||||||
|
"email":{"type":"string"},
|
||||||
|
"full_name":{"type":"string"},
|
||||||
|
"scopes":{"type":"array","items":{"type":"string"}},
|
||||||
|
"token_name":{"type":"string"},
|
||||||
|
"secret_name":{"type":"string","description":"name to store the minted token under"},
|
||||||
|
"grant_read_repos":{"type":"array","items":{"type":"string"},"description":"owner/repo strings"},
|
||||||
|
"confirm_service_account":{"type":"boolean"}
|
||||||
|
},
|
||||||
|
"required":["username","email","scopes","token_name","secret_name","confirm_service_account"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mintArgs struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
TokenName string `json:"token_name"`
|
||||||
|
SecretName string `json:"secret_name"`
|
||||||
|
GrantReadRepos []string `json:"grant_read_repos"`
|
||||||
|
ConfirmServiceAccount bool `json:"confirm_service_account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MintServiceAccountToken) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args mintArgs
|
||||||
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := make([]adminbootstrap.RepoRef, 0, len(args.GrantReadRepos))
|
||||||
|
for _, r := range args.GrantReadRepos {
|
||||||
|
owner, name, ok := strings.Cut(r, "/")
|
||||||
|
if !ok || owner == "" || name == "" {
|
||||||
|
return nil, fmt.Errorf("grant_read_repos entry %q must be owner/repo", r)
|
||||||
|
}
|
||||||
|
repos = append(repos, adminbootstrap.RepoRef{Owner: owner, Name: name})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := t.b.Run(ctx, adminbootstrap.Request{
|
||||||
|
Username: args.Username,
|
||||||
|
Email: args.Email,
|
||||||
|
FullName: args.FullName,
|
||||||
|
Scopes: args.Scopes,
|
||||||
|
TokenName: args.TokenName,
|
||||||
|
SecretName: args.SecretName,
|
||||||
|
GrantReadRepos: repos,
|
||||||
|
ConfirmServiceAccount: args.ConfirmServiceAccount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package localtools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This tool lives in its own package, never internal/tools, on purpose: it
|
||||||
|
// resets whatever password its target account has, which is far too
|
||||||
|
// dangerous a capability to risk it ever being wired into RegisterAll and
|
||||||
|
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
|
||||||
|
// separate, local-only, stdio binary) imports this package.
|
||||||
|
|
||||||
|
type fakeBootstrapper struct {
|
||||||
|
gotReq adminbootstrap.Request
|
||||||
|
result adminbootstrap.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
|
||||||
|
f.gotReq = req
|
||||||
|
return f.result, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
|
||||||
|
fb := &fakeBootstrapper{result: adminbootstrap.Result{
|
||||||
|
Username: "dispatch-bot", Created: true,
|
||||||
|
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
|
||||||
|
}}
|
||||||
|
tool := localtools.NewMintServiceAccountToken(fb)
|
||||||
|
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"username": "dispatch-bot",
|
||||||
|
"email": "dispatch@d-ma.be",
|
||||||
|
"scopes": []string{"read:repository", "read:issue"},
|
||||||
|
"token_name": "dispatch-ci-readonly",
|
||||||
|
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
||||||
|
"grant_read_repos": []string{"mathias/cobalt-dingo"},
|
||||||
|
"confirm_service_account": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := tool.Call(context.Background(), args)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
|
||||||
|
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
|
||||||
|
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
|
||||||
|
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
|
||||||
|
// The tool's own output must never carry a raw token or password field --
|
||||||
|
// adminbootstrap.Result structurally can't (it has none), which this pins.
|
||||||
|
_, hasToken := result["Token"]
|
||||||
|
_, hasPassword := result["Password"]
|
||||||
|
assert.False(t, hasToken)
|
||||||
|
assert.False(t, hasPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
|
||||||
|
fb := &fakeBootstrapper{}
|
||||||
|
tool := localtools.NewMintServiceAccountToken(fb)
|
||||||
|
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"username": "dispatch-bot",
|
||||||
|
"email": "dispatch@d-ma.be",
|
||||||
|
"scopes": []string{"read:repository"},
|
||||||
|
"token_name": "x",
|
||||||
|
"secret_name": "x",
|
||||||
|
"grant_read_repos": []string{"not-owner-slash-repo"},
|
||||||
|
"confirm_service_account": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), args)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "owner/repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
|
||||||
|
fb := &fakeBootstrapper{err: assert.AnError}
|
||||||
|
tool := localtools.NewMintServiceAccountToken(fb)
|
||||||
|
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"username": "dispatch-bot",
|
||||||
|
"email": "dispatch@d-ma.be",
|
||||||
|
"scopes": []string{"read:repository"},
|
||||||
|
"token_name": "x",
|
||||||
|
"secret_name": "x",
|
||||||
|
"confirm_service_account": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := tool.Call(context.Background(), args)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
|
||||||
|
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
|
||||||
|
d := tool.Descriptor()
|
||||||
|
assert.Equal(t, "mint_service_account_token", d.Name)
|
||||||
|
assert.NotEmpty(t, d.Description)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package localtools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpSecretStore implements adminbootstrap.SecretStore against the local
|
||||||
|
// `op` (1Password) CLI. Run is injectable for tests; nil defaults to
|
||||||
|
// actually invoking `op`.
|
||||||
|
//
|
||||||
|
// The token value is passed via the child process's stdin, never as a
|
||||||
|
// literal argv element — matching the estate's own secret-handling rule
|
||||||
|
// (a value in argv lands in process listings and any transcript of the
|
||||||
|
// command). `credential[password]=-` is `op item create`'s documented way
|
||||||
|
// to read one field's value from stdin instead of the command line.
|
||||||
|
type OpSecretStore struct {
|
||||||
|
Vault string
|
||||||
|
Run func(ctx context.Context, stdin string, args ...string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s OpSecretStore) Store(ctx context.Context, name, value string) (string, error) {
|
||||||
|
run := s.Run
|
||||||
|
if run == nil {
|
||||||
|
run = runOp
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"item", "create",
|
||||||
|
"--category=login",
|
||||||
|
"--vault=" + s.Vault,
|
||||||
|
"--title=" + name,
|
||||||
|
"credential[password]=-",
|
||||||
|
}
|
||||||
|
if err := run(ctx, value, args...); err != nil {
|
||||||
|
return "", fmt.Errorf("op item create: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("op://%s/%s/credential", s.Vault, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOp(ctx context.Context, stdin string, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "op", args...)
|
||||||
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package localtools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The estate's own secret-handling rule: never place a literal secret in a
|
||||||
|
// command's argv — it lands in process listings and any transcript of the
|
||||||
|
// command. OpSecretStore feeds the token to `op` via stdin instead.
|
||||||
|
|
||||||
|
func TestOpSecretStore_PassesValueViaStdinNeverArgv(t *testing.T) {
|
||||||
|
var gotStdin string
|
||||||
|
var gotArgs []string
|
||||||
|
s := localtools.OpSecretStore{
|
||||||
|
Vault: "AgentSecrets",
|
||||||
|
Run: func(_ context.Context, stdin string, args ...string) error {
|
||||||
|
gotStdin = stdin
|
||||||
|
gotArgs = args
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := s.Store(context.Background(), "gitea-dispatch-bot-ci-readonly", "tok_the_actual_secret_value")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential", ref)
|
||||||
|
assert.Equal(t, "tok_the_actual_secret_value", gotStdin)
|
||||||
|
for _, a := range gotArgs {
|
||||||
|
assert.NotContains(t, a, "tok_the_actual_secret_value",
|
||||||
|
"the secret value must never appear as a literal argv element")
|
||||||
|
}
|
||||||
|
assert.Contains(t, gotArgs, "--vault=AgentSecrets")
|
||||||
|
assert.Contains(t, gotArgs, "--title=gitea-dispatch-bot-ci-readonly")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpSecretStore_PropagatesRunError(t *testing.T) {
|
||||||
|
s := localtools.OpSecretStore{
|
||||||
|
Vault: "AgentSecrets",
|
||||||
|
Run: func(_ context.Context, _ string, _ ...string) error {
|
||||||
|
return assert.AnError
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.Store(context.Background(), "x", "y")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeStdio runs the MCP JSON-RPC protocol over stdio: one JSON-RPC message
|
||||||
|
// per line on in, one response per line on out — the standard MCP stdio
|
||||||
|
// framing, and how Claude Code spawns a LOCAL MCP server as a subprocess.
|
||||||
|
//
|
||||||
|
// Built for gitea-admin-mcp (see cmd/gitea-admin-mcp), which has to run
|
||||||
|
// locally where real admin credentials exist just-in-time, rather than
|
||||||
|
// through the hosted HTTP server (server.go) a sandboxed agent session talks
|
||||||
|
// to. The method dispatch below intentionally mirrors handlePOST in
|
||||||
|
// server.go — same three methods, same error codes — so a tool written
|
||||||
|
// against registry.Tool behaves identically over either transport; it is
|
||||||
|
// kept as a separate small loop rather than a shared refactor of the HTTP
|
||||||
|
// handler, which mixes in http.ResponseWriter specifics this transport
|
||||||
|
// doesn't have.
|
||||||
|
func ServeStdio(ctx context.Context, reg *registry.Registry, in io.Reader, out io.Writer) error {
|
||||||
|
scanner := bufio.NewScanner(in)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // 1 MiB max line, matches server.go's request cap
|
||||||
|
enc := json.NewEncoder(out)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(line, &req); err != nil {
|
||||||
|
if werr := enc.Encode(NewErrorResponse(nil, -32700, "parse error", nil)); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.ID == nil {
|
||||||
|
continue // notification — no response, per JSON-RPC 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := dispatch(ctx, reg, req)
|
||||||
|
if err := enc.Encode(resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch handles one already-parsed request. Mirrors server.go's
|
||||||
|
// handlePOST switch exactly (initialize / tools/list / tools/call / unknown).
|
||||||
|
func dispatch(ctx context.Context, reg *registry.Registry, req Request) Response {
|
||||||
|
if req.Method == "initialize" {
|
||||||
|
return NewResponse(req.ID, map[string]any{
|
||||||
|
"protocolVersion": ProtocolVersion,
|
||||||
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||||
|
"serverInfo": map[string]any{"name": "gitea-admin-mcp", "version": "0.1.0"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "tools/list":
|
||||||
|
return NewResponse(req.ID, map[string]any{"tools": reg.Tools()})
|
||||||
|
|
||||||
|
case "tools/call":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||||
|
return NewErrorResponse(req.ID, -32602, "invalid params", nil)
|
||||||
|
}
|
||||||
|
out, err := reg.Dispatch(ctx, p.Name, p.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
code := -32000
|
||||||
|
if errors.Is(err, registry.ErrToolNotFound) {
|
||||||
|
code = CodeNotFound
|
||||||
|
}
|
||||||
|
return NewErrorResponse(req.ID, code, err.Error(), nil)
|
||||||
|
}
|
||||||
|
return NewResponse(req.ID, map[string]any{
|
||||||
|
"content": []map[string]any{{"type": "text", "text": string(out)}},
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewErrorResponse(req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The existing MCP server (server.go) only speaks HTTP. gitea-admin-mcp needs
|
||||||
|
// to run as a LOCAL stdio server (Claude Code's local .mcp.json stdio server
|
||||||
|
// type, spawned as a subprocess with real 1Password/admin auth already in its
|
||||||
|
// environment) -- reusing the exact same registry.Tool interface and
|
||||||
|
// jsonrpc.go message types, one JSON-RPC message per line on stdin/stdout,
|
||||||
|
// which is the standard MCP stdio framing.
|
||||||
|
|
||||||
|
type echoTool struct{}
|
||||||
|
|
||||||
|
func (echoTool) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
|
||||||
|
}
|
||||||
|
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStdio_Initialize(t *testing.T) {
|
||||||
|
reg := registry.New()
|
||||||
|
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n")
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := mcp.ServeStdio(context.Background(), reg, in, out)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
var resp mcp.Response
|
||||||
|
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
|
||||||
|
assert.EqualValues(t, 1, resp.ID)
|
||||||
|
assert.Nil(t, resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStdio_ToolsListAndCall(t *testing.T) {
|
||||||
|
reg := registry.New()
|
||||||
|
reg.Register(echoTool{})
|
||||||
|
in := strings.NewReader(
|
||||||
|
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
|
||||||
|
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"hello":"world"}}}` + "\n",
|
||||||
|
)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := mcp.ServeStdio(context.Background(), reg, in, out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||||
|
require.Len(t, lines, 2, "one JSON-RPC response per line, in request order")
|
||||||
|
|
||||||
|
var listResp mcp.Response
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(lines[0]), &listResp))
|
||||||
|
assert.EqualValues(t, 1, listResp.ID)
|
||||||
|
|
||||||
|
var callResp mcp.Response
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(lines[1]), &callResp))
|
||||||
|
assert.EqualValues(t, 2, callResp.ID)
|
||||||
|
require.NotNil(t, callResp.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStdio_NotificationGetsNoResponse(t *testing.T) {
|
||||||
|
// A request with no "id" is a notification per JSON-RPC 2.0 -- must not
|
||||||
|
// write anything back, and must not stop the loop from processing what
|
||||||
|
// follows it.
|
||||||
|
reg := registry.New()
|
||||||
|
in := strings.NewReader(
|
||||||
|
`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
|
||||||
|
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n",
|
||||||
|
)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := mcp.ServeStdio(context.Background(), reg, in, out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||||
|
require.Len(t, lines, 1, "the notification must not produce a response line")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStdio_UnknownMethodReturnsJSONRPCError(t *testing.T) {
|
||||||
|
reg := registry.New()
|
||||||
|
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"not/a/real/method"}` + "\n")
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := mcp.ServeStdio(context.Background(), reg, in, out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var resp mcp.Response
|
||||||
|
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
|
||||||
|
require.NotNil(t, resp.Error)
|
||||||
|
assert.Equal(t, -32601, resp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeStdio_UnparseableLineReturnsParseError(t *testing.T) {
|
||||||
|
reg := registry.New()
|
||||||
|
in := strings.NewReader("not json at all\n")
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err := mcp.ServeStdio(context.Background(), reg, in, out)
|
||||||
|
require.NoError(t, err, "a malformed line must not crash the loop")
|
||||||
|
|
||||||
|
var resp mcp.Response
|
||||||
|
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
|
||||||
|
require.NotNil(t, resp.Error)
|
||||||
|
assert.Equal(t, -32700, resp.Error.Code)
|
||||||
|
}
|
||||||
@@ -7,27 +7,27 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// #36: every caller (claude.ai connector, gitea's own convention) sends the
|
// #38: `repo` (and `number`) are the canonical, idiomatic gitea/GitHub arg names
|
||||||
// repo identifier as `repo` and issue/PR index as `index`, but the tools
|
// that identifier tools advertise. `name` is kept as a back-compat alias via the
|
||||||
// declare them as `name` and `number`. Unmatched fields zero-valued the path
|
// bidirectional shim, so old `name` callers still work. `index`->`number` stays.
|
||||||
// segment and produced gitea's misleading /api/swagger 404. parseArgs now
|
// An explicit canonical (`repo`) always wins over its alias (`name`).
|
||||||
// aliases repo->name and index->number so the idiomatic call works.
|
|
||||||
func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args string
|
args string
|
||||||
wantPath string
|
wantPath string
|
||||||
}{
|
}{
|
||||||
{"repo+index aliases", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
{"canonical repo+number", `{"owner":"mathias","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
{"canonical name+number", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
{"repo+index alias", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
{"explicit name wins over repo", `{"owner":"mathias","name":"infra","repo":"ignored","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
{"legacy name alias", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"explicit repo wins over name", `{"owner":"mathias","name":"ignored","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -47,3 +47,28 @@ func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #38: identifier tools must ADVERTISE `repo` (not `name`) in their schema, so
|
||||||
|
// the contract a client reads matches what every caller sends. The two create
|
||||||
|
// tools keep `name` because there it means "name of the new repo", not an
|
||||||
|
// existing-repo identifier.
|
||||||
|
func TestRepoIsCanonicalInAdvertisedSchema(t *testing.T) {
|
||||||
|
c := gitea.NewClient("http://unused", "")
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
|
||||||
|
identifier := map[string]json.RawMessage{
|
||||||
|
"repo_get": tools.NewRepoGet(c, a).Descriptor().InputSchema,
|
||||||
|
"issue_get": tools.NewIssueGet(c, a).Descriptor().InputSchema,
|
||||||
|
"pr_merge": tools.NewPRMerge(c, a).Descriptor().InputSchema,
|
||||||
|
"repo_delete": tools.NewRepoDelete(c, a).Descriptor().InputSchema,
|
||||||
|
"file_read": tools.NewFileRead(c, a).Descriptor().InputSchema,
|
||||||
|
}
|
||||||
|
for name, sch := range identifier {
|
||||||
|
s := string(sch)
|
||||||
|
assert.Contains(t, s, `"repo":`, name+" must advertise repo")
|
||||||
|
assert.NotContains(t, s, `"name":`, name+" must not advertise name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create tools keep `name` (new resource name, not an existing-repo id)
|
||||||
|
assert.Contains(t, string(tools.NewRepoCreate(c, a).Descriptor().InputSchema), `"name":`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchDelete struct {
|
type BranchDelete struct {
|
||||||
@@ -27,17 +27,17 @@ func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"branch":{"type":"string"}
|
"branch":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","branch"]
|
"required":["owner","repo","branch"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchDeleteArgs struct {
|
type branchDeleteArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,14 +46,14 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Branch == "" {
|
if args.Branch == "" {
|
||||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
|
if err := t.c.DeleteBranch(ctx, args.Owner, args.Repo, args.Branch); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchList struct {
|
type BranchList struct {
|
||||||
@@ -26,18 +26,18 @@ func (t *BranchList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"page":{"type":"integer","minimum":1},
|
"page":{"type":"integer","minimum":1},
|
||||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchListArgs struct {
|
type branchListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
|
branches, err := t.c.ListBranches(ctx, args.Owner, args.Repo, args.Page, capLimit(args.Limit, 30))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchProtectionGet struct {
|
type BranchProtectionGet struct {
|
||||||
@@ -26,17 +26,17 @@ func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"branch":{"type":"string"}
|
"branch":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","branch"]
|
"required":["owner","repo","branch"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchProtectionGetArgs struct {
|
type branchProtectionGetArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +45,11 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
|
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, args.Branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type semaphore chan struct{}
|
type semaphore chan struct{}
|
||||||
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
|
|||||||
type codeSearchArgs struct {
|
type codeSearchArgs struct {
|
||||||
Q string `json:"q"`
|
Q string `json:"q"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo,omitempty"` // optional: empty => owner-wide fan-out (#37 opt-out)
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if args.Q == "" {
|
if args.Q == "" {
|
||||||
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Page < 1 {
|
if args.Page < 1 {
|
||||||
|
|||||||
@@ -2,36 +2,92 @@ package tools_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// for a set of repos — the real endpoints code_search's underlying SearchCode
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
// now uses (Gitea's REST API has no code-content-search endpoint; see
|
||||||
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
// internal/gitea/code_search.go's doc comment).
|
||||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
type multiRepoSearchFake struct {
|
||||||
|
owner string
|
||||||
|
repos []string // ListRepos response, in this order
|
||||||
|
files map[string]map[string]string // repo -> path -> content
|
||||||
|
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{
|
p := r.URL.Path
|
||||||
"data":[{
|
|
||||||
"path":"internal/gitea/repos.go",
|
for _, repo := range f.repos {
|
||||||
"snippet":"func (c *Client) ListRepos",
|
base := "/api/v1/repos/" + f.owner + "/" + repo
|
||||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
switch {
|
||||||
"score":3.0
|
case p == base && f.fail[repo]:
|
||||||
}],
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
"ok":true
|
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||||
}`))
|
return
|
||||||
}))
|
case p == base:
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(p, base+"/git/trees/"):
|
||||||
|
var entries []string
|
||||||
|
for path := range f.files[repo] {
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(p, base+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, base+"/contents/")
|
||||||
|
content, ok := f.files[repo][path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(content))
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p == "/api/v1/users/"+f.owner+"/repos" {
|
||||||
|
var entries []string
|
||||||
|
for _, repo := range f.repos {
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||||
|
f := &multiRepoSearchFake{
|
||||||
|
owner: "mathias",
|
||||||
|
repos: []string{"infra"},
|
||||||
|
files: map[string]map[string]string{
|
||||||
|
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(f.handler(t))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
|
|||||||
require.Len(t, result.Results, 1)
|
require.Len(t, result.Results, 1)
|
||||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||||
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||||
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||||
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"infra", "gitea-mcp"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"infra": {"main.go": "this is an infra hit\n"},
|
||||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
|
||||||
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
},
|
||||||
]`))
|
|
||||||
case "/api/v1/repos/mathias/infra/search":
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
}
|
||||||
}))
|
srv := httptest.NewServer(f.handler(t))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"infra", "broken"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"infra": {"main.go": "this is an infra hit\n"},
|
||||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
},
|
||||||
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
fail: map[string]bool{"broken": true},
|
||||||
]`))
|
|
||||||
case "/api/v1/repos/mathias/infra/search":
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/broken/search":
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
}
|
||||||
}))
|
srv := httptest.NewServer(f.handler(t))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -134,7 +175,9 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
Results []struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
} `json:"results"`
|
||||||
Partial bool `json:"partial"`
|
Partial bool `json:"partial"`
|
||||||
PartialRepos []string `json:"partial_repos"`
|
PartialRepos []string `json:"partial_repos"`
|
||||||
}
|
}
|
||||||
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"alpha", "beta"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
|
||||||
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
|
||||||
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
},
|
||||||
]`))
|
|
||||||
case "/api/v1/repos/mathias/alpha/search":
|
|
||||||
// low score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/beta/search":
|
|
||||||
// high score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
}
|
||||||
}))
|
srv := httptest.NewServer(f.handler(t))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
Snippet string `json:"snippet"`
|
Snippet string `json:"snippet"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
require.Len(t, result.Results, 2)
|
require.Len(t, result.Results, 2)
|
||||||
// First result must be the high-score one
|
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
|
||||||
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||||
"expected results sorted by score desc, got %v then %v",
|
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
|
||||||
result.Results[0].Score, result.Results[1].Score)
|
|
||||||
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||||
@@ -48,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
|||||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "create_project_from_template",
|
Name: "create_project_from_template",
|
||||||
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).",
|
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
@@ -56,8 +56,9 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|||||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean"},
|
"private":{"type":"boolean"},
|
||||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."},
|
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
|
||||||
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."}
|
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file (dispatch#3) AND register owner/name into mathias/dispatch's git-tracked allowlist (dispatch-repos.txt, dispatch#19) — both gates are required for the watcher to actually pick the repo up; each is applied independently and idempotently. Default false. Safe to re-request on resume."},
|
||||||
|
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -71,6 +72,7 @@ type createProjectArgs struct {
|
|||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
TemplateName string `json:"template_name"`
|
TemplateName string `json:"template_name"`
|
||||||
DispatchAllow bool `json:"dispatch_allow"`
|
DispatchAllow bool `json:"dispatch_allow"`
|
||||||
|
Resume bool `json:"resume"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
||||||
@@ -86,8 +88,22 @@ type createProjectResult struct {
|
|||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
FilesSubstituted []string `json:"files_substituted"`
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
|
||||||
|
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
|
||||||
|
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dispatch allowlist (dispatch#19) is a fixed integration point — a
|
||||||
|
// specific file in a specific repo the mathias/dispatch watcher reads at the
|
||||||
|
// start of every cycle. Hardcoded to match its own DISPATCH_ALLOWLIST_OWNER/
|
||||||
|
// _REPO/_PATH defaults (unconfigured in the live CronJob, confirmed 2026-07-06).
|
||||||
|
const (
|
||||||
|
dispatchAllowlistOwner = "mathias"
|
||||||
|
dispatchAllowlistRepo = "dispatch"
|
||||||
|
dispatchAllowlistPath = "dispatch-repos.txt"
|
||||||
|
dispatchAllowlistBranch = "main"
|
||||||
|
)
|
||||||
|
|
||||||
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
var args createProjectArgs
|
var args createProjectArgs
|
||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
@@ -95,7 +111,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allowlist check first.
|
// Allowlist check first.
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +120,94 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var result createProjectResult
|
||||||
|
var branch string
|
||||||
|
var err error
|
||||||
|
if args.Resume {
|
||||||
|
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
|
||||||
|
} else {
|
||||||
|
result, branch, err = t.createDestination(ctx, args)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
||||||
|
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
||||||
|
// A fixed known-files list can't rename directories or cover every templated
|
||||||
|
// file, which is why the old scaffold didn't build. GetTree reflects the
|
||||||
|
// branch's CURRENT state, which is what makes this loop safe to re-run on
|
||||||
|
// resume: a file already fixed in a prior partial pass shows up already-correct
|
||||||
|
// (or already-renamed) and substituteEntry is a no-op for it.
|
||||||
|
repls := substitutions(args.Owner, args.Name)
|
||||||
|
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
||||||
|
if terr != nil {
|
||||||
|
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range tree.Tree {
|
||||||
|
if e.Type != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
||||||
|
if fail != "" {
|
||||||
|
result.PartialFailure = fail
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if substituted != "" {
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt the new project into headless dispatch if asked. Two INDEPENDENT gates,
|
||||||
|
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
|
||||||
|
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
|
||||||
|
// allowlist. Skip both if substitution itself already stalled — don't mark an
|
||||||
|
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
|
||||||
|
// (gitea-mcp#51/#54) — never conflated with each other or with substitution,
|
||||||
|
// since any one of the three can fail independently of the other two.
|
||||||
|
if args.DispatchAllow && result.PartialFailure == "" {
|
||||||
|
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
|
||||||
|
result.DispatchAllowFailure = fail
|
||||||
|
} else if didWrite {
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerName := args.Owner + "/" + args.Name
|
||||||
|
if didRegister, fail := t.registerDispatchAllowlist(ctx, ownerName); fail != "" {
|
||||||
|
result.DispatchAllowlistFailure = fail
|
||||||
|
} else if didRegister {
|
||||||
|
result.DispatchAllowlisted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If substitution stalled because the generated branch wasn't writable in time,
|
||||||
|
// the repo IS created — say so clearly and point to the concrete recovery step
|
||||||
|
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
|
||||||
|
// gitea's template-generate is slow-async on this instance, so tool-side
|
||||||
|
// substitution is best-effort).
|
||||||
|
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
||||||
|
strings.Contains(result.PartialFailure, "not found") {
|
||||||
|
result.PartialFailure = infra179FinalizeMessage(
|
||||||
|
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail loud on a FRESH create with nothing substituted: a real template
|
||||||
|
// should have placeholders, so finding none is suspicious. On resume, nothing
|
||||||
|
// left to substitute is the expected steady state once a prior partial run is
|
||||||
|
// fully caught up — success, not a loud failure.
|
||||||
|
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
||||||
|
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDestination generates a new repo from the template: verifies the
|
||||||
|
// template exists and the destination doesn't already exist, then calls
|
||||||
|
// gitea's /generate and resolves the default branch.
|
||||||
|
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
|
||||||
// Resolve template: per-call override takes precedence over the
|
// Resolve template: per-call override takes precedence over the
|
||||||
// server-configured default. Owner stays server-configured.
|
// server-configured default. Owner stays server-configured.
|
||||||
tmplName := args.TemplateName
|
tmplName := args.TemplateName
|
||||||
@@ -114,17 +218,19 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
// Verify template exists and is marked as a template repo.
|
// Verify template exists and is marked as a template repo.
|
||||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("template lookup: %w", err)
|
return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
|
||||||
}
|
}
|
||||||
if !tmpl.Template {
|
if !tmpl.Template {
|
||||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify destination doesn't already exist.
|
// Verify destination doesn't already exist.
|
||||||
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
|
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
|
||||||
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
|
return createProjectResult{}, "", fmt.Errorf(
|
||||||
|
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
|
||||||
|
args.Owner, args.Name, gitea.ErrConflict)
|
||||||
} else if !errors.Is(err, gitea.ErrNotFound) {
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("destination check: %w", err)
|
return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate repo from template.
|
// Generate repo from template.
|
||||||
@@ -136,7 +242,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
GitContent: true,
|
GitContent: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate: %w", err)
|
return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := createProjectResult{
|
result := createProjectResult{
|
||||||
@@ -158,73 +264,113 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.DefaultBranch = branch
|
result.DefaultBranch = branch
|
||||||
|
return result, branch, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
|
||||||
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
|
||||||
// A fixed known-files list can't rename directories or cover every templated
|
// nothing to resume without it. Template lookup and generation are skipped
|
||||||
// file, which is why the old scaffold didn't build.
|
// entirely: resume only ever operates on the destination.
|
||||||
repls := substitutions(args.Owner, args.Name)
|
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
|
||||||
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
repo, err := t.c.GetRepo(ctx, owner, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err)
|
if errors.Is(err, gitea.ErrNotFound) {
|
||||||
return textOK(result)
|
return createProjectResult{}, "", fmt.Errorf(
|
||||||
|
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
|
||||||
|
owner, name, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
|
||||||
|
}
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
return createProjectResult{
|
||||||
|
FullName: repo.FullName,
|
||||||
|
HTMLURL: repo.HTMLURL,
|
||||||
|
CloneURL: repo.CloneURL,
|
||||||
|
DefaultBranch: branch,
|
||||||
|
}, branch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range tree.Tree {
|
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
|
||||||
if e.Type != "blob" {
|
// on every resume, not just the first attempt): creates it if absent, updates
|
||||||
continue
|
// it if present but different, leaves it untouched if already correct. Without
|
||||||
}
|
// this, a naive create-only write would error on a re-invoke (gitea rejects a
|
||||||
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
// create at a path that already exists) — that was the reported failure in
|
||||||
if fail != "" {
|
// gitea-mcp#51. Returns whether a write actually happened.
|
||||||
result.PartialFailure = fail
|
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
|
||||||
break
|
const path = ".dispatch-allow"
|
||||||
}
|
sha := ""
|
||||||
if substituted != "" {
|
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
|
||||||
|
return false, "" // already present and correct — idempotent no-op
|
||||||
}
|
}
|
||||||
|
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
|
||||||
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return false, fmt.Sprintf("read %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt the new project into headless dispatch if asked: presence of a
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||||
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
|
||||||
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
|
|
||||||
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
|
|
||||||
// loop already stalled — a failed injection then degrades identically.
|
|
||||||
if args.DispatchAllow && result.PartialFailure == "" {
|
|
||||||
const dispatchAllowPath = ".dispatch-allow"
|
|
||||||
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
|
|
||||||
Branch: branch,
|
Branch: branch,
|
||||||
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
||||||
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
||||||
|
Sha: sha,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
|
return false, fmt.Sprintf("write %s: %v", path, err)
|
||||||
} else {
|
}
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDispatchAllowlist appends ownerName ("owner/name") to
|
||||||
|
// mathias/dispatch's git-tracked dispatch-repos.txt if not already listed
|
||||||
|
// (gitea-mcp#54) — idempotent, safe to call on every resume. This is the
|
||||||
|
// SECOND of the two required dispatch gates: being listed here is necessary
|
||||||
|
// but not sufficient on its own (the repo also needs its own .dispatch-allow
|
||||||
|
// marker, injectDispatchAllow's job) — dispatch#3's structural trust-zone
|
||||||
|
// design requires both independently. Returns whether a write actually
|
||||||
|
// happened, so the caller only reports a fresh registration, not a no-op.
|
||||||
|
func (t *CreateProjectFromTemplate) registerDispatchAllowlist(ctx context.Context, ownerName string) (didRegister bool, failure string) {
|
||||||
|
fc, err := t.c.GetFileContents(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, dispatchAllowlistBranch)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("read %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("decode %s: %v", dispatchAllowlistPath, err)
|
||||||
|
}
|
||||||
|
content := string(decoded)
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.TrimSpace(line) == ownerName {
|
||||||
|
return false, "" // already listed — idempotent no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If substitution stalled because the generated branch wasn't writable in time,
|
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
|
||||||
// the repo IS created — say so clearly and point to the local finalize step,
|
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
|
||||||
// rather than leaking the raw "branch does not exist" (infra#179: gitea's
|
Branch: dispatchAllowlistBranch,
|
||||||
// template-generate is slow-async on this instance, so tool-side substitution
|
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
|
||||||
// is best-effort).
|
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
|
||||||
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
Sha: fc.Sha,
|
||||||
strings.Contains(result.PartialFailure, "not found") {
|
}); err != nil {
|
||||||
result.PartialFailure = fmt.Sprintf(
|
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
||||||
|
// async template-generate (infra#179) leaves the branch unwritable within the
|
||||||
|
// budget. It points at the concrete recovery step — re-invoking this same tool
|
||||||
|
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
|
||||||
|
// resume safely continues from wherever substitution stalled.
|
||||||
|
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
||||||
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
||||||
"is incomplete (%d file(s) done). Finalize locally with `hyperguild new-project` "+
|
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
|
||||||
"(clone + substitute, no API race). Underlying: %s",
|
"(same owner/name) once the branch is writable — it safely continues where this left "+
|
||||||
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
"off, skipping anything already correct. Underlying: %s",
|
||||||
}
|
branch, budget, done, underlying)
|
||||||
|
|
||||||
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
|
|
||||||
// substituted (with no explicit failure) means the walk found no placeholders —
|
|
||||||
// suspicious for a real template. Surface it instead of returning silent success.
|
|
||||||
if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
|
||||||
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
return textOK(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// substitutionBudget bounds how long we retry the first write while the freshly
|
// substitutionBudget bounds how long we retry the first write while the freshly
|
||||||
@@ -232,7 +378,8 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
||||||
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
||||||
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
||||||
// fails fast and we defer substitution with clear guidance rather than hang.
|
// fails fast with partial_failure naming resume=true as the recovery path
|
||||||
|
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
|
||||||
const substitutionBudget = 5
|
const substitutionBudget = 5
|
||||||
|
|
||||||
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
||||||
@@ -283,21 +430,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|||||||
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
||||||
|
|
||||||
if renamed {
|
if renamed {
|
||||||
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
|
||||||
Branch: branch,
|
|
||||||
Content: enc,
|
|
||||||
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Sprintf("write %s: %v", newPath, err)
|
|
||||||
}
|
|
||||||
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
|
|
||||||
Branch: branch,
|
|
||||||
Sha: fc.Sha,
|
|
||||||
Message: fmt.Sprintf("template: drop placeholder path %s", path),
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Sprintf("delete %s: %v", path, err)
|
|
||||||
}
|
|
||||||
return path + " -> " + newPath, ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||||
@@ -310,3 +443,45 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|||||||
}
|
}
|
||||||
return path, ""
|
return path, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
|
||||||
|
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
|
||||||
|
// are separate, non-atomic API calls) completes cleanly instead of erroring on
|
||||||
|
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
|
||||||
|
// already holds the correct content, the write is skipped and only the
|
||||||
|
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
|
||||||
|
// is a no-op too.
|
||||||
|
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
|
||||||
|
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
|
||||||
|
if derr == nil && string(decoded) == newContent {
|
||||||
|
break // already correct from a prior partial run — skip the write
|
||||||
|
}
|
||||||
|
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch, Content: enc, Sha: existing.Sha,
|
||||||
|
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
|
||||||
|
}); writeErr != nil {
|
||||||
|
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
|
||||||
|
}
|
||||||
|
case errors.Is(err, gitea.ErrNotFound):
|
||||||
|
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch, Content: enc,
|
||||||
|
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
|
||||||
|
}); writeErr != nil {
|
||||||
|
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Sprintf("read %s: %v", newPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Sha: oldSha,
|
||||||
|
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
|
||||||
|
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
|
||||||
|
}
|
||||||
|
return oldPath + " -> " + newPath, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -33,16 +33,32 @@ type fakeTemplateServer struct {
|
|||||||
files map[string]string // path -> raw (un-substituted) content
|
files map[string]string // path -> raw (un-substituted) content
|
||||||
genBranch string // default_branch returned by /generate ("" to force fallback)
|
genBranch string // default_branch returned by /generate ("" to force fallback)
|
||||||
generated bool
|
generated bool
|
||||||
|
generateCalls int // # times POST .../generate was hit — resume must never increment this
|
||||||
puts []string
|
puts []string
|
||||||
deletes []string
|
deletes []string
|
||||||
putBodies map[string]string // path -> decoded written content
|
putBodies map[string]string // path -> decoded written content
|
||||||
repoGetsPost int // GET dest after generate (branch fallback)
|
repoGetsPost int // GET dest after generate (branch fallback)
|
||||||
|
|
||||||
|
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
|
||||||
|
// "" (default) 404s the read, matching a repo that hasn't seeded the file
|
||||||
|
// (a distinct error path); tests that care set it explicitly.
|
||||||
|
dispatchRepos string
|
||||||
|
dispatchReposPuts int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||||
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newFakeTemplateServerResumed simulates a repo that already exists from a
|
||||||
|
// prior (real) generate call — GET dest succeeds immediately, without a
|
||||||
|
// /generate call first. Used for resume:true tests.
|
||||||
|
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||||
|
f := newFakeTemplateServer(files, genBranch)
|
||||||
|
f.generated = true
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
|
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -50,7 +66,28 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
p := r.URL.Path
|
p := r.URL.Path
|
||||||
|
|
||||||
|
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == http.MethodGet && p == dispatchReposPath:
|
||||||
|
if f.dispatchRepos == "" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPut && p == dispatchReposPath:
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
var args struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &args)
|
||||||
|
dec, _ := base64.StdEncoding.DecodeString(args.Content)
|
||||||
|
f.dispatchRepos = string(dec)
|
||||||
|
f.dispatchReposPuts++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`))
|
||||||
|
|
||||||
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
||||||
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
||||||
|
|
||||||
@@ -65,6 +102,7 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl
|
|||||||
|
|
||||||
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
||||||
f.generated = true
|
f.generated = true
|
||||||
|
f.generateCalls++
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||||
dest, dest, f.genBranch, dest, dest)
|
dest, dest, f.genBranch, dest, dest)
|
||||||
@@ -139,6 +177,9 @@ type createOut struct {
|
|||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
FilesSubstituted []string `json:"files_substituted"`
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
|
||||||
|
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
|
||||||
|
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Happy path: whole-tree substitution, content + path rename, correct module host.
|
// Happy path: whole-tree substitution, content + path rename, correct module host.
|
||||||
@@ -258,6 +299,229 @@ func TestCreateProject_DispatchAllow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
|
||||||
|
|
||||||
|
// resume:true requires an ALREADY-CREATED destination — there is nothing to
|
||||||
|
// resume otherwise.
|
||||||
|
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
|
||||||
|
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Contains(t, err.Error(), "nothing to resume")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resume:true on an existing repo continues substitution — fixes only what's
|
||||||
|
// still wrong, leaves already-correct files untouched, and never calls
|
||||||
|
// /generate again (the whole point: no re-creation, just continuation).
|
||||||
|
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
|
||||||
|
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
|
||||||
|
}
|
||||||
|
f := newFakeTemplateServerResumed(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
assert.Equal(t, 0, f.generateCalls, "resume must never call /generate")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "README.md")
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
|
||||||
|
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
|
||||||
|
assert.Contains(t, f.puts, "README.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resume:true when everything is already substituted is the expected steady
|
||||||
|
// state (a prior resume already finished the job, or this is a redundant
|
||||||
|
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
|
||||||
|
// that a fresh (non-resume) create would trigger.
|
||||||
|
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
|
||||||
|
"README.md": "# new-svc\n",
|
||||||
|
}
|
||||||
|
f := newFakeTemplateServerResumed(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.FilesSubstituted)
|
||||||
|
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resume where a prior partial run's rename write SUCCEEDED but its paired
|
||||||
|
// delete FAILED (both are separate, non-atomic API calls) must complete
|
||||||
|
// cleanly: recognize the new path is already correct, skip re-writing it, and
|
||||||
|
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
|
||||||
|
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
// stray: delete never completed in the prior run
|
||||||
|
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||||
|
// already correct: the write half of the same prior rename DID complete
|
||||||
|
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||||
|
}
|
||||||
|
f := newFakeTemplateServerResumed(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||||
|
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
|
||||||
|
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
|
||||||
|
// has the correct content (from an earlier successful injection), re-invoking
|
||||||
|
// must not attempt another write — and must not error the way a naive
|
||||||
|
// create-only write would (gitea 409/422 on an existing path with no sha).
|
||||||
|
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
|
||||||
|
// Phase 1: a normal (non-resume) call captures the REAL content the tool
|
||||||
|
// writes for .dispatch-allow, without the test needing to know the exact
|
||||||
|
// unexported constant.
|
||||||
|
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
seedSrv := newFakeTemplateServer(seedFiles, "main")
|
||||||
|
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||||
|
srv1.Close()
|
||||||
|
require.Empty(t, out1.PartialFailure)
|
||||||
|
realContent := seedSrv.putBodies[".dispatch-allow"]
|
||||||
|
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
|
||||||
|
|
||||||
|
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
|
||||||
|
// one file still needing substitution.
|
||||||
|
files := map[string]string{
|
||||||
|
".dispatch-allow": realContent,
|
||||||
|
"README.md": "# __PROJECT_NAME__\n",
|
||||||
|
}
|
||||||
|
f := newFakeTemplateServerResumed(files, "main")
|
||||||
|
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv2.Close()
|
||||||
|
|
||||||
|
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out2.PartialFailure)
|
||||||
|
assert.Empty(t, out2.DispatchAllowFailure)
|
||||||
|
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
|
||||||
|
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
|
||||||
|
assert.Contains(t, out2.FilesSubstituted, "README.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch_allow injection failing is reported in its OWN field, distinct from
|
||||||
|
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
|
||||||
|
// eligibility still fails, and the caller must be able to tell them apart.
|
||||||
|
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
base := f.handler(t, "template-go-agent", "new-svc")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure")
|
||||||
|
assert.NotEmpty(t, out.DispatchAllowFailure)
|
||||||
|
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch_allow now ALSO registers the new repo into mathias/dispatch's
|
||||||
|
// git-tracked allowlist (dispatch-repos.txt) — the second of the two required
|
||||||
|
// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54).
|
||||||
|
func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n"
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
assert.Empty(t, out.DispatchAllowlistFailure)
|
||||||
|
assert.True(t, out.DispatchAllowlisted)
|
||||||
|
assert.Equal(t, 1, f.dispatchReposPuts)
|
||||||
|
assert.Contains(t, f.dispatchRepos, "mathias/new-svc")
|
||||||
|
// existing entries preserved, not clobbered
|
||||||
|
assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox")
|
||||||
|
assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registering is idempotent: a repo already listed (e.g. a resume re-running
|
||||||
|
// with dispatch_allow:true) must not produce a duplicate line or a redundant write.
|
||||||
|
func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted
|
||||||
|
f := newFakeTemplateServerResumed(files, "main")
|
||||||
|
f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n"
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
assert.Empty(t, out.DispatchAllowlistFailure)
|
||||||
|
assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration")
|
||||||
|
assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure registering the allowlist is reported in its OWN field — distinct
|
||||||
|
// from PartialFailure (substitution) AND DispatchAllowFailure (the marker
|
||||||
|
// file) — since all three are independent gates that can fail independently.
|
||||||
|
func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
f.dispatchRepos = "mathias/dispatch-sandbox\n"
|
||||||
|
base := f.handler(t, "template-go-agent", "new-svc")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure, "substitution succeeded — must not be conflated")
|
||||||
|
assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated")
|
||||||
|
assert.NotEmpty(t, out.DispatchAllowlistFailure)
|
||||||
|
assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch_allow=false/omitted must never touch the allowlist file at all.
|
||||||
|
func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
assert.False(t, out.DispatchAllowlisted)
|
||||||
|
assert.Equal(t, 0, f.dispatchReposPuts)
|
||||||
|
}
|
||||||
|
|
||||||
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||||
|
|
||||||
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #46: the infra#179 finalize guidance must not point at a non-existent command
|
||||||
|
// (`hyperguild new-project` was never built). #50 superseded the original
|
||||||
|
// manual-editing guidance with a concrete, real recovery: re-invoke this same
|
||||||
|
// tool with resume=true.
|
||||||
|
func TestInfra179FinalizeMessage(t *testing.T) {
|
||||||
|
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
|
||||||
|
|
||||||
|
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("message missing %q\ngot: %s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "hyperguild new-project") {
|
||||||
|
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DirList struct {
|
type DirList struct {
|
||||||
@@ -26,18 +26,18 @@ func (t *DirList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"ref":{"type":"string"}
|
"ref":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type dirListArgs struct {
|
type dirListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
|
entries, err := t.c.ListContents(ctx, args.Owner, args.Repo, args.Path, args.Ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileDelete struct {
|
type FileDelete struct {
|
||||||
@@ -27,20 +27,20 @@ func (t *FileDelete) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"branch":{"type":"string"},
|
"branch":{"type":"string"},
|
||||||
"message":{"type":"string"},
|
"message":{"type":"string"},
|
||||||
"sha":{"type":"string"}
|
"sha":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path","branch","message","sha"]
|
"required":["owner","repo","path","branch","message","sha"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileDeleteArgs struct {
|
type fileDeleteArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -52,7 +52,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Sha == "" {
|
if args.Sha == "" {
|
||||||
@@ -62,7 +62,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
|
result, err := t.c.DeleteFile(ctx, args.Owner, args.Repo, args.Path, gitea.DeleteFileArgs{
|
||||||
Branch: args.Branch,
|
Branch: args.Branch,
|
||||||
Message: args.Message,
|
Message: args.Message,
|
||||||
Sha: args.Sha,
|
Sha: args.Sha,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const fileReadMaxBytes = 1 << 20 // 1 MiB
|
const fileReadMaxBytes = 1 << 20 // 1 MiB
|
||||||
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"ref":{"type":"string"}
|
"ref":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path"]
|
"required":["owner","repo","path"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileReadArgs struct {
|
type fileReadArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
}
|
}
|
||||||
@@ -51,20 +51,20 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ref := args.Ref
|
ref := args.Ref
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
var err error
|
var err error
|
||||||
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileWriteBranch struct {
|
type FileWriteBranch struct {
|
||||||
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
|
|||||||
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "file_write_branch",
|
Name: "file_write_branch",
|
||||||
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
|
Description: "Create or update a file on the given branch. Pass an existing branch — e.g. the default branch `main` — to commit directly to it (trunk-based); a branch that doesn't exist yet is created from `base` first (PR flow). Pair with pr_create/pr_merge for the branch→PR→merge path.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"content":{"type":"string"},
|
"content":{"type":"string"},
|
||||||
"branch":{"type":"string"},
|
"branch":{"type":"string"},
|
||||||
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
|||||||
"message":{"type":"string"},
|
"message":{"type":"string"},
|
||||||
"sha":{"type":"string"}
|
"sha":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path","content","branch","message"]
|
"required":["owner","repo","path","content","branch","message"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileWriteBranchArgs struct {
|
type fileWriteBranchArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Branch == "" {
|
if args.Branch == "" {
|
||||||
@@ -68,7 +68,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve base default if branch needs to be created
|
// Resolve base default if branch needs to be created
|
||||||
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
|
exists, err := t.c.BranchExists(ctx, args.Owner, args.Repo, args.Branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
base := args.Base
|
base := args.Base
|
||||||
if base == "" {
|
if base == "" {
|
||||||
var err error
|
var err error
|
||||||
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, args.Branch, base); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
|
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
|
||||||
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
|
result, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
|
||||||
Branch: args.Branch,
|
Branch: args.Branch,
|
||||||
Content: encoded,
|
Content: encoded,
|
||||||
Message: args.Message,
|
Message: args.Message,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueClose struct {
|
type IssueClose struct {
|
||||||
@@ -26,17 +26,17 @@ func (t *IssueClose) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueCloseArgs struct {
|
type issueCloseArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "closed")
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueComment struct {
|
type IssueComment struct {
|
||||||
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1},
|
"number":{"type":"integer","minimum":1},
|
||||||
"body":{"type":"string"}
|
"body":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number","body"]
|
"required":["owner","repo","number","body"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueCommentArgs struct {
|
type issueCommentArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Number < 1 {
|
if args.Number < 1 {
|
||||||
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueCreate struct {
|
type IssueCreate struct {
|
||||||
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"title":{"type":"string"},
|
"title":{"type":"string"},
|
||||||
"body":{"type":"string"},
|
"body":{"type":"string"},
|
||||||
"labels":{"type":"array","items":{"type":"integer"}},
|
"labels":{"type":"array","items":{"type":"integer"}},
|
||||||
"assignees":{"type":"array","items":{"type":"string"}},
|
"assignees":{"type":"array","items":{"type":"string"}},
|
||||||
"milestone":{"type":"integer"}
|
"milestone":{"type":"integer"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","title"]
|
"required":["owner","repo","title"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueCreateArgs struct {
|
type issueCreateArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Labels []int64 `json:"labels"`
|
Labels []int64 `json:"labels"`
|
||||||
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Title == "" {
|
if args.Title == "" {
|
||||||
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
|
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Repo, gitea.CreateIssueArgs{
|
||||||
Title: args.Title,
|
Title: args.Title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Labels: args.Labels,
|
Labels: args.Labels,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueEdit struct {
|
type IssueEdit struct {
|
||||||
@@ -30,19 +30,19 @@ func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1},
|
"number":{"type":"integer","minimum":1},
|
||||||
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
|
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
|
||||||
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
|
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueEditArgs struct {
|
type issueEditArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
Body *string `json:"body,omitempty"`
|
Body *string `json:"body,omitempty"`
|
||||||
@@ -53,7 +53,7 @@ func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Number < 1 {
|
if args.Number < 1 {
|
||||||
@@ -63,7 +63,7 @@ func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
iss, err := t.c.EditIssue(ctx, args.Owner, args.Name, args.Number, gitea.EditIssueArgs{
|
iss, err := t.c.EditIssue(ctx, args.Owner, args.Repo, args.Number, gitea.EditIssueArgs{
|
||||||
Title: args.Title,
|
Title: args.Title,
|
||||||
Body: args.Body,
|
Body: args.Body,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueGet struct {
|
type IssueGet struct {
|
||||||
@@ -24,17 +24,17 @@ func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueGetArgs struct {
|
type issueGetArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +43,10 @@ func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
|
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueLabel struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
|
||||||
|
return &IssueLabel{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_label",
|
||||||
|
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"labels":{"type":"array","items":{"type":"string"},"description":"Label names to resolve and apply. Either labels or label_ids is required."},
|
||||||
|
"label_ids":{"type":"array","items":{"type":"integer"},"description":"Label IDs to apply directly, skipping name resolution. Either labels or label_ids is required."}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueLabelArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
Labels []string `json:"labels,omitempty"`
|
||||||
|
LabelIDs []int64 `json:"label_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueLabelArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if len(args.Labels) == 0 && len(args.LabelIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := append([]int64{}, args.LabelIDs...)
|
||||||
|
if len(args.Labels) > 0 {
|
||||||
|
existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byName := make(map[string]int64, len(existing))
|
||||||
|
for _, l := range existing {
|
||||||
|
byName[l.Name] = l.ID
|
||||||
|
}
|
||||||
|
for _, name := range args.Labels {
|
||||||
|
id, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(labels)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const labelListFixture = `[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||||
|
]`
|
||||||
|
|
||||||
|
func TestIssueLabelAppliesByName(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
ids, ok := payload["labels"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
|
||||||
|
|
||||||
|
assert.Contains(t, string(out), `"name":"bug"`)
|
||||||
|
assert.Contains(t, string(out), `"name":"enhancement"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// label_ids alone (no labels) must work end-to-end without hitting ListLabels
|
||||||
|
// at all — this is the schema-level "either labels or label_ids" contract, and
|
||||||
|
// it must never require a GET to the label list when the caller already has IDs.
|
||||||
|
func TestIssueLabelAppliesByIDOnly(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
var listCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
|
||||||
|
listCalled = true
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"label_ids":[1,2]}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, listCalled, "label_ids-only must not call ListLabels")
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
ids, ok := payload["labels"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
|
||||||
|
assert.Contains(t, string(out), `"name":"bug"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #52 review finding: the advertised schema wrongly required "labels", making
|
||||||
|
// label_ids-only calls fail JSON-Schema validation before reaching Call at all.
|
||||||
|
// Lock the fixed contract: neither is individually required.
|
||||||
|
func TestIssueLabelSchema_NeitherLabelsNorLabelIDsRequired(t *testing.T) {
|
||||||
|
sch := string(tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})).Descriptor().InputSchema)
|
||||||
|
assert.NotContains(t, sch, `"required":["owner","repo","number","labels"]`)
|
||||||
|
assert.Contains(t, sch, `"required":["owner","repo","number"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Contains(t, err.Error(), `"does-not-exist"`)
|
||||||
|
assert.Contains(t, err.Error(), "o/r")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelRequiresValidNumber(t *testing.T) {
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueList struct {
|
type IssueList struct {
|
||||||
@@ -26,21 +26,21 @@ func (t *IssueList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"state":{"type":"string","enum":["open","closed","all"]},
|
"state":{"type":"string","enum":["open","closed","all"]},
|
||||||
"labels":{"type":"string"},
|
"labels":{"type":"string"},
|
||||||
"since":{"type":"string"},
|
"since":{"type":"string"},
|
||||||
"page":{"type":"integer","minimum":1},
|
"page":{"type":"integer","minimum":1},
|
||||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueListArgs struct {
|
type issueListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Labels string `json:"labels"`
|
Labels string `json:"labels"`
|
||||||
Since string `json:"since"`
|
Since string `json:"since"`
|
||||||
@@ -53,7 +53,7 @@ func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.State == "" {
|
if args.State == "" {
|
||||||
@@ -63,7 +63,7 @@ func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
if args.Page < 1 {
|
if args.Page < 1 {
|
||||||
args.Page = 1
|
args.Page = 1
|
||||||
}
|
}
|
||||||
issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, gitea.ListIssuesArgs{
|
issues, err := t.c.ListIssues(ctx, args.Owner, args.Repo, gitea.ListIssuesArgs{
|
||||||
State: args.State,
|
State: args.State,
|
||||||
Labels: args.Labels,
|
Labels: args.Labels,
|
||||||
Since: args.Since,
|
Since: args.Since,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueListComments struct {
|
type IssueListComments struct {
|
||||||
@@ -26,17 +26,17 @@ func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueListCommentsArgs struct {
|
type issueListCommentsArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Name, args.Number)
|
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueReopen struct {
|
type IssueReopen struct {
|
||||||
@@ -26,17 +26,17 @@ func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueReopenArgs struct {
|
type issueReopenArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "open")
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LabelList struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
|
||||||
|
return &LabelList{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LabelList) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "label_list",
|
||||||
|
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"}
|
||||||
|
},
|
||||||
|
"required":["owner","repo"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelListArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args labelListArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(labels)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLabelListTool(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/labels", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"id":1`)
|
||||||
|
assert.Contains(t, string(out), `"name":"bug"`)
|
||||||
|
assert.Contains(t, string(out), `"color":"ee0701"`)
|
||||||
|
assert.Contains(t, string(out), `"name":"enhancement"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelListAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRComment struct {
|
type PRComment struct {
|
||||||
@@ -29,18 +29,18 @@ func (t *PRComment) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1},
|
"number":{"type":"integer","minimum":1},
|
||||||
"body":{"type":"string"}
|
"body":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number","body"]
|
"required":["owner","repo","number","body"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prCommentArgs struct {
|
type prCommentArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
if err := parseArgs(raw, &args); err != nil {
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := t.a.Check(args.Owner); err != nil {
|
if err := t.a.Check(ctx, args.Owner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args.Number < 1 {
|
if args.Number < 1 {
|
||||||
@@ -61,7 +61,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user