Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3faffc4c4 | ||
|
|
dbf78a3b34 | ||
|
|
c3f8d6c002 | ||
|
|
dc127a05b5 | ||
|
|
cd61239451 | ||
|
|
31a3dd3e0e | ||
|
|
ae50501ba3 | ||
|
|
9396b4daaa | ||
|
|
0e2bd3d90e | ||
|
|
b149dccdbe | ||
|
|
724a997002 | ||
|
|
59dabc9455 | ||
|
|
6b57a8ba8a | ||
|
|
e1e9a2e30e | ||
|
|
09bde6df8a | ||
|
|
aaaa4b4d64 | ||
|
|
2bb1fef543 | ||
|
|
ee9ac99c40 | ||
|
|
ef7ccc8295 | ||
|
|
4d6597639e | ||
|
|
419ae19cdb | ||
|
|
e4d07c106c | ||
|
|
1d75bf9b25 | ||
|
|
417ab60707 | ||
|
|
f6446c2515 | ||
|
|
50c447f673 | ||
|
|
43714047be | ||
|
|
5601927dc8 | ||
|
|
a16c5b0537 | ||
|
|
1eceb5f7fe | ||
|
|
09a7fad6ba | ||
|
|
02af7ee71c | ||
|
|
240c3ec081 | ||
|
|
51b823ae79 | ||
|
|
ac337955e7 | ||
|
|
b288462a1c | ||
|
|
82823aad1e |
@@ -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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
|
||||||
|
|
||||||
| Task type | Load |
|
|
||||||
|-----------|------|
|
|
||||||
| Any feature or bug fix | `tdd` |
|
|
||||||
| Refactor or design | `clean-code` or `solid` |
|
|
||||||
| Debug | `problem-analysis` |
|
|
||||||
| Review code or PRs | `code-review` |
|
|
||||||
| Frame a problem before coding | `problem-analysis` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: gitea-mcp
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
|
||||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
|
||||||
- No feature branches, no PRs for solo/agent work.
|
|
||||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
|
||||||
`agent/<description>` branch and merge within the same session.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## Knowledge base access
|
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task in this project, regardless of harness.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. Commit directly to main. Run `task check` before every push. Never create
|
|
||||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current state — v0.2.5 (2026-05-17)
|
|
||||||
|
|
||||||
All v0.2 work is complete and deployed. No active sprint.
|
|
||||||
|
|
||||||
### What shipped
|
|
||||||
|
|
||||||
| Tag | PR | Tools / fixes |
|
|
||||||
|-----|----|---------------|
|
|
||||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
|
||||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
|
||||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
|
||||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
|
||||||
|
|
||||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
|
||||||
|
|
||||||
### Next up
|
|
||||||
|
|
||||||
1. **`hyperguild new-project` v1** — primary next target.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
|
||||||
|
|
||||||
2. **Issue #19** — end-to-end mirror flow verification.
|
|
||||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
|
||||||
+9
-4
@@ -2,21 +2,26 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"brain": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://brain-mcp.d-ma.be/mcp",
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://git-mcp.d-ma.be/mcp",
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"infra": {
|
"infra": {
|
||||||
|
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://infra-mcp.d-ma.be/mcp"
|
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-25
@@ -53,14 +53,51 @@ 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.
|
||||||
|
|
||||||
|
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||||
|
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||||
|
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||||
|
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||||
|
PR would buy a review of a state that was never deployed.
|
||||||
|
|
||||||
|
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||||
|
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||||
|
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||||
|
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||||
|
green" is not the same claim.
|
||||||
|
|
||||||
|
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||||
|
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||||
|
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||||
|
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||||
|
|
||||||
|
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||||
|
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||||
|
lane silently does not apply.
|
||||||
|
|
||||||
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 +115,63 @@ 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.
|
||||||
|
|
||||||
|
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||||
|
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||||
|
landed, the state changed, you verified it live — close/merge/check it off
|
||||||
|
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||||
|
on me to confirm something that already happened; that's bookkeeping, not
|
||||||
|
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||||
|
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||||
|
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||||
|
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||||
|
destructive-git already gate. Before claiming something is closable,
|
||||||
|
verify it live (re-check the issue, re-run the command) rather than
|
||||||
|
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||||
|
|
||||||
|
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||||
|
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||||
|
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||||
|
passing suite only asserts what its author already thought of, and for a
|
||||||
|
control the author is precisely the person whose imagination just failed.
|
||||||
|
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||||
|
execution, and **CI was green on every one of them** while `git rm
|
||||||
|
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||||
|
route this work through a review with authority to block, instructed to
|
||||||
|
verify by execution rather than by reading the implementation. Expect to be
|
||||||
|
blocked more than once; that is the control working, not the reviewer being
|
||||||
|
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||||
|
practices live in the `pr-gatekeeper` agent.)
|
||||||
|
|
||||||
|
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||||
|
after the mechanism it observes, not the goal it serves.
|
||||||
|
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||||
|
support. This is not pedantry about naming — when name and assertion
|
||||||
|
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||||
|
which is what lets the defect survive review. Found three times in one PR,
|
||||||
|
twice in tests written to fix the previous instance of it; each passed while
|
||||||
|
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||||
|
you do not yet know what the test observes.
|
||||||
|
|
||||||
|
## 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 +182,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 +191,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 +234,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
|
||||||
@@ -205,10 +304,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **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 |
|
| **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":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **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`.
|
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||||
|
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||||
|
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||||
|
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||||
|
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||||
|
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||||
|
were not.)
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -234,10 +339,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,26 +355,65 @@ 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
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||||
unconditionally on every host, every harness.
|
host and every harness.
|
||||||
|
|
||||||
|
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||||
|
must state its reason.** Both halves matter: the project file wins because it knows
|
||||||
|
something this file cannot (infra's Flux constraint is real and not visible from
|
||||||
|
here), and it must say why because a bare override is indistinguishable from drift.
|
||||||
|
|
||||||
|
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||||
|
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||||
|
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||||
|
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||||
|
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||||
|
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
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:**
|
||||||
|
|
||||||
|
|||||||
-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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
|
||||||
|
|
||||||
| Task type | Load |
|
|
||||||
|-----------|------|
|
|
||||||
| Any feature or bug fix | `tdd` |
|
|
||||||
| Refactor or design | `clean-code` or `solid` |
|
|
||||||
| Debug | `problem-analysis` |
|
|
||||||
| Review code or PRs | `code-review` |
|
|
||||||
| Frame a problem before coding | `problem-analysis` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: gitea-mcp
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
|
||||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
|
||||||
- No feature branches, no PRs for solo/agent work.
|
|
||||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
|
||||||
`agent/<description>` branch and merge within the same session.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## Knowledge base access
|
|
||||||
|
|
||||||
This project can query the shared knowledge base via MCP or HTTP:
|
|
||||||
|
|
||||||
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
|
|
||||||
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
|
|
||||||
- **Scoping**: queries are filtered to collection `personal` + `public`
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task in this project, regardless of harness.
|
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. Commit directly to main. Run `task check` before every push. Never create
|
|
||||||
feature branches unless a parallel agent is simultaneously active on this repo.
|
|
||||||
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
|
|
||||||
## Current state — v0.2.5 (2026-05-17)
|
|
||||||
|
|
||||||
All v0.2 work is complete and deployed. No active sprint.
|
|
||||||
|
|
||||||
### What shipped
|
|
||||||
|
|
||||||
| Tag | PR | Tools / fixes |
|
|
||||||
|-----|----|---------------|
|
|
||||||
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
|
||||||
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
|
||||||
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
|
||||||
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
|
||||||
|
|
||||||
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
|
||||||
|
|
||||||
### Next up
|
|
||||||
|
|
||||||
1. **`hyperguild new-project` v1** — primary next target.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
|
||||||
|
|
||||||
2. **Issue #19** — end-to-end mirror flow verification.
|
|
||||||
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
|
||||||
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"
|
||||||
+53
-32
@@ -4,6 +4,16 @@ 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/**'
|
||||||
|
# gitea-mcp#68: without this a PR never got a CI run, so the risk/medium+
|
||||||
|
# PR lane merged on local `task check` alone. Only the check job runs for a
|
||||||
|
# PR -- build and deploy stay push/tag-only (no images, no infra writes,
|
||||||
|
# no secrets from a PR).
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea-mcp
|
IMAGE: gitea-mcp
|
||||||
@@ -40,6 +50,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build & Import
|
name: Build & Import
|
||||||
needs: check
|
needs: check
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
outputs:
|
outputs:
|
||||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||||
@@ -48,47 +59,50 @@ jobs:
|
|||||||
|
|
||||||
- name: Derive image tags
|
- name: Derive image tags
|
||||||
id: meta
|
id: meta
|
||||||
|
env:
|
||||||
|
# signals#47: a ref name can carry $(...); pass it via env, not ${{ }}.
|
||||||
|
REF: ${{ github.ref }}
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
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`,
|
||||||
# Stamp the real build version: the git tag on a v* build, else the short sha.
|
# and infra's drift gate names any repo still carrying an old copy.
|
||||||
VERSION="${{ steps.meta.outputs.version-tag }}"
|
env:
|
||||||
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
|
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
|
||||||
buildah build \
|
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
|
||||||
--build-arg VERSION="${VERSION}" \
|
GIT_SHA: ${{ github.sha }}
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
IMAGE_SOURCE: ${{ github.repositoryUrl }}
|
||||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
|
||||||
-t ${REF} \
|
run: bash .gitea/build-image.sh
|
||||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
|
||||||
.
|
|
||||||
buildah push --tls-verify=false ${REF}
|
|
||||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
|
||||||
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
|
|
||||||
buildah push --tls-verify=false \
|
|
||||||
${REF} \
|
|
||||||
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
|
||||||
echo "✓ Image pushed to ${REF}"
|
|
||||||
|
|
||||||
- name: Smoke test
|
# 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:
|
||||||
@@ -104,6 +118,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
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Canonical golangci-lint configuration for the estate's Go repos (infra#309
|
||||||
|
# pattern: one canonical file here, a byte-identical copy in each adopter,
|
||||||
|
# and the drift gate in THIS repo comparing sha256).
|
||||||
|
#
|
||||||
|
# Why this exists: 26 Go repos, 3 had a lint config, and all three were
|
||||||
|
# different. The 23 without one ran golangci-lint's v2 default set, which
|
||||||
|
# does NOT include a formatter -- so gofmt/gofumpt drift went uncaught until
|
||||||
|
# it was found by hand (dma-cli#12: two files drifted, nothing flagged them).
|
||||||
|
#
|
||||||
|
# The two configs this replaces (cobalt-dingo, coo-agent) were near-identical
|
||||||
|
# copies of each other AND both invalid under v2: they carried v1-style
|
||||||
|
# `linters-settings` and `issues.exclude-rules` keys, which `golangci-lint
|
||||||
|
# config verify` rejects outright. Their revive settings and test exclusions
|
||||||
|
# were therefore not in effect. Consolidating fixes that too.
|
||||||
|
#
|
||||||
|
# Per-repo variation does NOT belong here -- this file must stay byte-identical
|
||||||
|
# across adopters so the gate is a checksum, not a diff a human has to judge.
|
||||||
|
# A repo with a genuine exception (agentsquad excludes its seeded-bug fixture
|
||||||
|
# tree, which exists precisely to contain broken code) keeps its own config and
|
||||||
|
# is not listed as an adopter.
|
||||||
|
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- errcheck # unchecked errors
|
||||||
|
- govet # suspicious constructs
|
||||||
|
- staticcheck # broad static analysis
|
||||||
|
- unused # unused code
|
||||||
|
- misspell # spelling
|
||||||
|
- revive # idiomatic Go
|
||||||
|
- exhaustive # exhaustive switch on enums
|
||||||
|
|
||||||
|
settings:
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
# Test files: table-driven cases legitimately carry unused struct fields
|
||||||
|
# and long functions.
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
- unused
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt
|
||||||
@@ -48,14 +48,51 @@ 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.
|
||||||
|
|
||||||
|
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||||
|
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||||
|
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||||
|
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||||
|
PR would buy a review of a state that was never deployed.
|
||||||
|
|
||||||
|
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||||
|
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||||
|
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||||
|
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||||
|
green" is not the same claim.
|
||||||
|
|
||||||
|
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||||
|
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||||
|
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||||
|
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||||
|
|
||||||
|
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||||
|
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||||
|
lane silently does not apply.
|
||||||
|
|
||||||
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 +110,63 @@ 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.
|
||||||
|
|
||||||
|
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||||
|
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||||
|
landed, the state changed, you verified it live — close/merge/check it off
|
||||||
|
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||||
|
on me to confirm something that already happened; that's bookkeeping, not
|
||||||
|
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||||
|
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||||
|
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||||
|
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||||
|
destructive-git already gate. Before claiming something is closable,
|
||||||
|
verify it live (re-check the issue, re-run the command) rather than
|
||||||
|
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||||
|
|
||||||
|
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||||
|
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||||
|
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||||
|
passing suite only asserts what its author already thought of, and for a
|
||||||
|
control the author is precisely the person whose imagination just failed.
|
||||||
|
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||||
|
execution, and **CI was green on every one of them** while `git rm
|
||||||
|
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||||
|
route this work through a review with authority to block, instructed to
|
||||||
|
verify by execution rather than by reading the implementation. Expect to be
|
||||||
|
blocked more than once; that is the control working, not the reviewer being
|
||||||
|
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||||
|
practices live in the `pr-gatekeeper` agent.)
|
||||||
|
|
||||||
|
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||||
|
after the mechanism it observes, not the goal it serves.
|
||||||
|
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||||
|
support. This is not pedantry about naming — when name and assertion
|
||||||
|
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||||
|
which is what lets the defect survive review. Found three times in one PR,
|
||||||
|
twice in tests written to fix the previous instance of it; each passed while
|
||||||
|
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||||
|
you do not yet know what the test observes.
|
||||||
|
|
||||||
|
## 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 +177,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 +186,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 +229,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
|
||||||
@@ -200,10 +299,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **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 |
|
| **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":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
||||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **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`.
|
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||||
|
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||||
|
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||||
|
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||||
|
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||||
|
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||||
|
were not.)
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -229,10 +334,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,26 +350,65 @@ 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
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||||
unconditionally on every host, every harness.
|
host and every harness.
|
||||||
|
|
||||||
|
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||||
|
must state its reason.** Both halves matter: the project file wins because it knows
|
||||||
|
something this file cannot (infra's Flux constraint is real and not visible from
|
||||||
|
here), and it must say why because a bare override is indistinguishable from drift.
|
||||||
|
|
||||||
|
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||||
|
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||||
|
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||||
|
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||||
|
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||||
|
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
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:**
|
||||||
|
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -22,15 +23,67 @@ type jwtStatus struct {
|
|||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
// readinessFn reports whether JWT validation is currently possible, and why
|
||||||
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
// not. chassisauth.JWTValidator.Ready satisfies it.
|
||||||
if initErr != nil {
|
type readinessFn func(context.Context) (bool, error)
|
||||||
status.JWT.LastError = initErr.Error()
|
|
||||||
}
|
// newHealthzHandler reports the CURRENT state, evaluated per request.
|
||||||
body, _ := json.Marshal(status)
|
//
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
// It used to marshal one body at startup and serve those bytes forever. That is
|
||||||
|
// how gitea-mcp reported jwt.status=degraded for six days after Authentik had
|
||||||
|
// recovered (mcp-chassis#4): the answer was computed before the IdP was up and
|
||||||
|
// could never change. Fixing the validator alone would not have fixed this —
|
||||||
|
// the validator would recover and /healthz would keep saying degraded.
|
||||||
|
//
|
||||||
|
// Calling ready() per request is also what drives recovery: the chassis resolves
|
||||||
|
// a pending issuer on demand, so every probe is a retry.
|
||||||
|
func newHealthzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := healthStatus{OK: true, JWT: jwtStatus{Status: "disabled"}}
|
||||||
|
if dexConfigured {
|
||||||
|
ok, err := ready(r.Context())
|
||||||
|
status.JWT.Status = jwtStatusFor(true, ok)
|
||||||
|
if err != nil {
|
||||||
|
status.JWT.LastError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write(body)
|
_ = json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newReadyzHandler is the kubelet's probe: 503 while JWT auth is degraded.
|
||||||
|
//
|
||||||
|
// /healthz stays 200 deliberately — the process is alive and a liveness probe
|
||||||
|
// must not restart it, because restarting does not make the IdP come back any
|
||||||
|
// sooner. Readiness is the right lever: k8s stops routing OAuth callers to a pod
|
||||||
|
// that would 401 them, and the probe's own cadence becomes the retry loop with
|
||||||
|
// no background goroutine to own.
|
||||||
|
//
|
||||||
|
// A server with no issuer configured is always ready. Making readiness depend on
|
||||||
|
// an issuer it was never given would pull every static-token-only MCP out of its
|
||||||
|
// Service.
|
||||||
|
func newReadyzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !dexConfigured {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := ready(r.Context())
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
msg := ""
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(healthStatus{
|
||||||
|
OK: false,
|
||||||
|
JWT: jwtStatus{Status: "degraded", LastError: msg},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readinessFunc is what the handler asks. Named here so the test can drive it.
|
||||||
|
type fakeReadiness struct{ calls int }
|
||||||
|
|
||||||
|
// ready reports not-ready on the first call and ready afterwards — the shape of
|
||||||
|
// an IdP that was down when the process booted and came back a minute later.
|
||||||
|
func (f *fakeReadiness) ready(context.Context) (bool, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.calls == 1 {
|
||||||
|
return false, errBoot
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBoot = &bootErr{}
|
||||||
|
|
||||||
|
type bootErr struct{}
|
||||||
|
|
||||||
|
func (*bootErr) Error() string { return "oidc discovery: status 503" }
|
||||||
|
|
||||||
|
// A health endpoint that answers from a value captured at startup is not a
|
||||||
|
// health endpoint.
|
||||||
|
//
|
||||||
|
// newHealthzHandler marshalled its body ONCE and served the same bytes for the
|
||||||
|
// process lifetime. On 2026-09-14 gitea-mcp reported jwt.status=degraded for six
|
||||||
|
// days after Authentik recovered, because the answer had been computed before
|
||||||
|
// Authentik was up and could never change. The pod was 1/1 Running and
|
||||||
|
// readiness-green throughout.
|
||||||
|
//
|
||||||
|
// Fixing mcp-chassis#4 alone does not fix this: the validator would recover and
|
||||||
|
// /healthz would keep saying degraded.
|
||||||
|
func TestHealthz_ReflectsCurrentState_NotBootState(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newHealthzHandler(true, f.ready)
|
||||||
|
|
||||||
|
first := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "degraded", first.JWT.Status, "first call: issuer still down")
|
||||||
|
require.NotEmpty(t, first.JWT.LastError, "degraded must say why")
|
||||||
|
|
||||||
|
second := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "enabled", second.JWT.Status,
|
||||||
|
"the issuer recovered; /healthz must notice without a restart")
|
||||||
|
require.Empty(t, second.JWT.LastError)
|
||||||
|
|
||||||
|
require.Equal(t, 2, f.calls, "readiness must be evaluated per request, not cached at startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no issuer configured, JWT auth is disabled by choice and readiness is
|
||||||
|
// never consulted — a static-token-only server must not be reported degraded.
|
||||||
|
func TestHealthz_NoIssuer_IsDisabledNotDegraded(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newHealthzHandler(false, f.ready)
|
||||||
|
|
||||||
|
got := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "disabled", got.JWT.Status)
|
||||||
|
require.Zero(t, f.calls, "no issuer configured means nothing to probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyz is what the kubelet probes. It must FAIL while JWT auth is degraded, so
|
||||||
|
// k8s stops routing to a pod that will 401 every OAuth caller — and so the
|
||||||
|
// probe's own cadence becomes the retry loop.
|
||||||
|
func TestReadyz_FailsWhileDegraded_ThenPasses(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newReadyzHandler(true, f.ready)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code,
|
||||||
|
"a pod that cannot validate any JWT must not be in the Service")
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "recovered without a restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A static-token-only server is always ready. Making readyz depend on an issuer
|
||||||
|
// it was never given would take every static-only MCP out of its Service.
|
||||||
|
func TestReadyz_NoIssuer_AlwaysReady(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newReadyzHandler(false, f.ready)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Zero(t, f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHealth(t *testing.T, h http.HandlerFunc) healthStatus {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
var got healthStatus
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
return got
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -43,7 +44,11 @@ func TestHealthzHandler(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
// Readiness is now a function the handler calls per request rather
|
||||||
|
// than two values captured at startup (mcp-chassis#4). The cases
|
||||||
|
// below are unchanged; only how the state reaches the handler is.
|
||||||
|
ready := func(context.Context) (bool, error) { return tc.validatorReady, tc.initErr }
|
||||||
|
h := newHealthzHandler(tc.dexConfigured, ready)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+37
-6
@@ -37,9 +37,34 @@ 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/
|
||||||
if jwtInitErr != nil {
|
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
|
||||||
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
// (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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No retry-without-the-k8s-issuer dance any more, and no "JWT auth degraded"
|
||||||
|
// warning to act on. Since mcp-chassis v0.6.0 an unreachable issuer does not
|
||||||
|
// fail construction: each issuer resolves independently and lazily, so one
|
||||||
|
// down issuer degrades only itself and recovers on its own. The fallback this
|
||||||
|
// replaced existed precisely because a single unreachable issuer used to take
|
||||||
|
// the whole validator with it.
|
||||||
|
jwtValidator, err := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||||
|
if err != nil {
|
||||||
|
// Only a configuration error can reach here now.
|
||||||
|
logger.Error("jwt validator config invalid", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
@@ -63,11 +88,17 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||||
auth.CallerMiddleware(logger, mcpSrv),
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
|
auth.CallerMiddleware(logger, mcpSrv),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
// Readiness is evaluated per request, and evaluating it is what retries a
|
||||||
|
// pending issuer — so the kubelet's probe cadence is the recovery loop.
|
||||||
|
dexConfigured := cfg.DexIssuerURL != ""
|
||||||
|
mux.Handle("/healthz", newHealthzHandler(dexConfigured, jwtValidator.Ready))
|
||||||
|
mux.Handle("/readyz", newReadyzHandler(dexConfigured, jwtValidator.Ready))
|
||||||
if cfg.DexIssuerURL != "" {
|
if cfg.DexIssuerURL != "" {
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||||
|
|||||||
@@ -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,207 @@
|
|||||||
|
# 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) |
|
||||||
|
|
||||||
|
**`tbd_ship` merge gate (gitea-mcp#63).** A PR is squash-merged only when ALL of: the head commit's CI is fully green,
|
||||||
|
the base branch is not review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`.
|
||||||
|
No `issue`, no `risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that
|
||||||
|
cannot be read all fail closed: the PR is opened, `merged:false`, with a `reason` and the `risk` tier in the result.
|
||||||
|
Unclassified is not low. Limit: the gate enforces the tier of the issue the caller names; it cannot prove that issue is
|
||||||
|
the one the change belongs to, so the link is the caller's claim (the convention in local-dev rule 5 still applies).
|
||||||
|
|
||||||
|
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}`.
|
||||||
@@ -3,7 +3,7 @@ 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.3.0
|
git.d-ma.be/mathias/mcp-chassis v0.7.1
|
||||||
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,11 @@
|
|||||||
git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
||||||
git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.0 h1:ct31v1F1PMhMCva/V0ErEbgMzwnT+27lYKbug2A/quE=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.1/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"
|
||||||
|
|
||||||
"git.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, ""))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
|||||||
"x_forwarded_user", fwdUser)
|
"x_forwarded_user", fwdUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
// gitea-mcp#60. This middleware logged nothing at all, so there
|
||||||
|
// was no way to tell a PAT pass-through request from one that
|
||||||
|
// fell through to the shared JWT/static path — only inference
|
||||||
|
// from which failures were absent.
|
||||||
|
//
|
||||||
|
// It matters more here than on the other two paths: this is
|
||||||
|
// where a request acts as a SPECIFIC Gitea user. An audit trail
|
||||||
|
// that cannot say which identity performed a write is not an
|
||||||
|
// audit trail.
|
||||||
|
//
|
||||||
|
// chassisauth.Fingerprint, not a local equivalent: the chassis
|
||||||
|
// emits the same field on its own accepted/rejected lines, and
|
||||||
|
// two different fingerprints for one token cannot be correlated.
|
||||||
|
slog.Info("mcp auth accepted",
|
||||||
|
"auth_path", "passthrough",
|
||||||
|
"caller", login,
|
||||||
|
"token_fp", chassisauth.Fingerprint(token),
|
||||||
|
"path", r.URL.Path,
|
||||||
|
)
|
||||||
|
ctx := withCaller(r.Context(), login)
|
||||||
|
ctx = gitea.WithToken(ctx, token)
|
||||||
|
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A token that is not a valid PAT falls through SILENTLY. The
|
||||||
|
// chassis owns the verdict; logging here too would double-count
|
||||||
|
// every static-token request, since all of them reach this
|
||||||
|
// middleware first.
|
||||||
|
}
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubValidator struct{ login string }
|
||||||
|
|
||||||
|
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
|
||||||
|
return s.login, s.login != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
|
||||||
|
//
|
||||||
|
// While verifying #59 end to end there was no way to confirm from the server
|
||||||
|
// logs whether calls were using the new PAT pass-through (caller's own Gitea
|
||||||
|
// PAT, own identity) or falling through to the shared JWT/static path. The only
|
||||||
|
// way to reason about it was inference. The chassis names its two paths as of
|
||||||
|
// v0.7.0; this is the third.
|
||||||
|
//
|
||||||
|
// This matters more than the other two: pass-through is the path where the
|
||||||
|
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
|
||||||
|
// identity performed a write is not an audit trail.
|
||||||
|
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
reached := false
|
||||||
|
h := PassthroughMiddleware(stubValidator{login: "alice"},
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
require.True(t, reached)
|
||||||
|
|
||||||
|
var line map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
|
||||||
|
require.Equal(t, "mcp auth accepted", line["msg"])
|
||||||
|
require.Equal(t, "passthrough", line["auth_path"])
|
||||||
|
require.Equal(t, "alice", line["caller"])
|
||||||
|
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
|
||||||
|
|
||||||
|
for k, v := range line {
|
||||||
|
require.NotContains(t, []any{v}, "a-real-gitea-pat",
|
||||||
|
"the PAT must never appear in any field, including %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that is not a valid PAT must fall through silently to the chassis,
|
||||||
|
// which owns the rejection log. Logging here as well would double-count every
|
||||||
|
// static-token request — every one of them reaches this middleware first.
|
||||||
|
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
fellBack := false
|
||||||
|
h := PassthroughMiddleware(stubValidator{},
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
require.True(t, fellBack)
|
||||||
|
require.Empty(t, strings.TrimSpace(buf.String()),
|
||||||
|
"the chassis owns the verdict; logging here too would double-count every static-token call")
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
|||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/v1/repos/mathias//issues", // empty repo
|
"/api/v1/repos/mathias//issues", // empty repo
|
||||||
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||||
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||||
}
|
}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
|
|||||||
@@ -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,8 +2,12 @@ package gitea_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -11,22 +15,65 @@ import (
|
|||||||
"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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ 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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -255,4 +255,3 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func TestCreateRepo_Org(t *testing.T) {
|
|||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
Name: "infra",
|
Name: "infra",
|
||||||
Org: "hyperguild",
|
Org: "hyperguild",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditTargetKeys are the only argument values copied into the audit record:
|
||||||
|
// they identify what a call acted on without copying what it wrote. Every
|
||||||
|
// other argument (file content, issue bodies) is reduced to args_bytes so the
|
||||||
|
// audit trail never becomes a second copy of repo content (gitea-mcp#64).
|
||||||
|
var auditTargetKeys = []string{"owner", "repo", "name", "number", "index", "branch", "path", "tag"}
|
||||||
|
|
||||||
|
// logToolCall emits one structured record per tools/call (OWASP MCP08).
|
||||||
|
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
|
||||||
|
attrs := []any{
|
||||||
|
"tool", name,
|
||||||
|
"caller", auth.Caller(ctx),
|
||||||
|
"args_bytes", len(args),
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
if tok, ok := gitea.TokenFromContext(ctx); ok {
|
||||||
|
attrs = append(attrs, "token_fp", chassisauth.Fingerprint(tok))
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if json.Unmarshal(args, &parsed) == nil {
|
||||||
|
for _, k := range auditTargetKeys {
|
||||||
|
if v, ok := parsed[k]; ok {
|
||||||
|
attrs = append(attrs, k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome := "ok"
|
||||||
|
if err != nil {
|
||||||
|
outcome = err.Error()
|
||||||
|
}
|
||||||
|
attrs = append(attrs, "outcome", outcome)
|
||||||
|
slog.InfoContext(ctx, "mcp tool call", attrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type auditStubTool struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s auditStubTool) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{Name: s.name, InputSchema: json.RawMessage(`{"type":"object"}`)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s auditStubTool) Call(context.Context, json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return json.RawMessage(`{"ok":true}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureAudit routes slog to a buffer and returns the decoded
|
||||||
|
// "mcp tool call" records emitted while fn runs.
|
||||||
|
func captureAudit(t *testing.T, fn func()) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||||
|
if l == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(l), &rec))
|
||||||
|
if rec["msg"] == "mcp tool call" {
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditServer(tools ...registry.Tool) http.Handler {
|
||||||
|
reg := registry.New()
|
||||||
|
for _, tl := range tools {
|
||||||
|
reg.Register(tl)
|
||||||
|
}
|
||||||
|
srv := mcp.NewServer(mcp.ServerOptions{Registry: reg})
|
||||||
|
return auth.CallerMiddleware(slog.Default(), srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(t *testing.T, h http.Handler, name string, args any) {
|
||||||
|
t.Helper()
|
||||||
|
body := map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": args},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Auth-Request-User", "alice")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitea-mcp#64: tools/call dispatched with no record at all.
|
||||||
|
func TestToolsCallLogsOneAuditRecordWithToolCallerAndTarget(t *testing.T) {
|
||||||
|
recs := captureAudit(t, func() {
|
||||||
|
callTool(t, auditServer(auditStubTool{name: "repo_delete"}), "repo_delete",
|
||||||
|
map[string]any{"owner": "mathias", "repo": "scratch", "confirm": "scratch"})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
r := recs[0]
|
||||||
|
assert.Equal(t, "repo_delete", r["tool"])
|
||||||
|
assert.Equal(t, "alice", r["caller"])
|
||||||
|
assert.Equal(t, "mathias", r["owner"])
|
||||||
|
assert.Equal(t, "scratch", r["repo"])
|
||||||
|
assert.Equal(t, "ok", r["outcome"])
|
||||||
|
assert.Contains(t, r, "duration_ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsCallAuditRecordsErrorOutcome(t *testing.T) {
|
||||||
|
recs := captureAudit(t, func() {
|
||||||
|
callTool(t, auditServer(auditStubTool{name: "pr_merge", err: errors.New("boom")}),
|
||||||
|
"pr_merge", map[string]any{"owner": "mathias", "repo": "x", "number": 3})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "boom", recs[0]["outcome"])
|
||||||
|
assert.EqualValues(t, 3, recs[0]["number"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit line must not become a second copy of repo content.
|
||||||
|
func TestToolsCallAuditOmitsArgumentValuesOtherThanTarget(t *testing.T) {
|
||||||
|
const sentinel = "SENTINEL-FILE-CONTENT-7f3a"
|
||||||
|
recs := captureAudit(t, func() {
|
||||||
|
callTool(t, auditServer(auditStubTool{name: "file_write_branch"}), "file_write_branch",
|
||||||
|
map[string]any{"owner": "mathias", "repo": "x", "path": "a.txt", "content": sentinel})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
raw, err := json.Marshal(recs[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(raw), sentinel)
|
||||||
|
assert.Contains(t, recs[0], "args_bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// #38 keeps `name` (for repo) and `index` (for number) as legacy aliases; a
|
||||||
|
// caller using them must still leave a target in the audit record.
|
||||||
|
func TestToolsCallAuditRecordsLegacyAliasTargets(t *testing.T) {
|
||||||
|
recs := captureAudit(t, func() {
|
||||||
|
callTool(t, auditServer(auditStubTool{name: "issue_close"}), "issue_close",
|
||||||
|
map[string]any{"owner": "mathias", "name": "infra", "index": 7})
|
||||||
|
})
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "infra", recs[0]["name"])
|
||||||
|
assert.EqualValues(t, 7, recs[0]["index"])
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProtocolVersion = "2025-03-26"
|
ProtocolVersion = "2025-03-26"
|
||||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,7 +91,9 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
NewErrorResponse(req.ID, -32602, "invalid params", nil))
|
NewErrorResponse(req.ID, -32602, "invalid params", nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
|
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
|
||||||
|
logToolCall(r.Context(), p.Name, p.Arguments, start, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := -32000
|
code := -32000
|
||||||
if errors.Is(err, registry.ErrToolNotFound) {
|
if errors.Is(err, registry.ErrToolNotFound) {
|
||||||
|
|||||||
@@ -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,116 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ 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 == "" {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +2,10 @@ 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"
|
||||||
@@ -16,22 +18,76 @@ import (
|
|||||||
"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":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
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":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/broken/search":
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
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,9 +175,11 @@ 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 {
|
||||||
Partial bool `json:"partial"`
|
Repo string `json:"repo"`
|
||||||
PartialRepos []string `json:"partial_repos"`
|
} `json:"results"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
|
PartialRepos []string `json:"partial_repos"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
assert.True(t, result.Partial)
|
assert.True(t, result.Partial)
|
||||||
@@ -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":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
// low score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/beta/search":
|
|
||||||
// high score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
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"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 finish substituting the placeholders manually. 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
|
||||||
@@ -80,14 +82,28 @@ const dispatchAllowContent = "# Presence of this file marks this repo as opt-in
|
|||||||
"# See dispatch#3.\n"
|
"# See dispatch#3.\n"
|
||||||
|
|
||||||
type createProjectResult struct {
|
type createProjectResult struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
CloneURL string `json:"clone_url"`
|
CloneURL string `json:"clone_url"`
|
||||||
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,83 +264,112 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
|
||||||
|
// on every resume, not just the first attempt): creates it if absent, updates
|
||||||
|
// it if present but different, leaves it untouched if already correct. Without
|
||||||
|
// this, a naive create-only write would error on a re-invoke (gitea rejects a
|
||||||
|
// create at a path that already exists) — that was the reported failure in
|
||||||
|
// gitea-mcp#51. Returns whether a write actually happened.
|
||||||
|
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
|
||||||
|
const path = ".dispatch-allow"
|
||||||
|
sha := ""
|
||||||
|
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
|
||||||
|
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
|
||||||
|
return false, "" // already present and correct — idempotent no-op
|
||||||
|
}
|
||||||
|
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
|
||||||
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return false, fmt.Sprintf("read %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range tree.Tree {
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||||
if e.Type != "blob" {
|
Branch: branch,
|
||||||
continue
|
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
||||||
}
|
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
||||||
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
Sha: sha,
|
||||||
if fail != "" {
|
}); err != nil {
|
||||||
result.PartialFailure = fail
|
return false, fmt.Sprintf("write %s: %v", path, err)
|
||||||
break
|
}
|
||||||
}
|
return true, ""
|
||||||
if substituted != "" {
|
}
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opt the new project into headless dispatch if asked: presence of a
|
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
|
||||||
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
|
||||||
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
|
Branch: dispatchAllowlistBranch,
|
||||||
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
|
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
|
||||||
// loop already stalled — a failed injection then degrades identically.
|
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
|
||||||
if args.DispatchAllow && result.PartialFailure == "" {
|
Sha: fc.Sha,
|
||||||
const dispatchAllowPath = ".dispatch-allow"
|
}); err != nil {
|
||||||
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
|
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
|
||||||
Branch: branch,
|
|
||||||
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
|
||||||
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
|
||||||
}); err != nil {
|
|
||||||
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
|
|
||||||
} else {
|
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return true, ""
|
||||||
// If substitution stalled because the generated branch wasn't writable in time,
|
|
||||||
// the repo IS created — say so clearly and point to the local finalize step,
|
|
||||||
// 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: 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
||||||
// async template-generate (infra#179) leaves the branch unwritable within the
|
// async template-generate (infra#179) leaves the branch unwritable within the
|
||||||
// budget. It names the concrete remaining work — substituting the two
|
// budget. It points at the concrete recovery step — re-invoking this same tool
|
||||||
// placeholders — rather than pointing at a specific tool, so the guidance stays
|
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
|
||||||
// correct regardless of scaffolding-CLI state (gitea-mcp#46).
|
// resume safely continues from wherever substitution stalled.
|
||||||
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
||||||
return fmt.Sprintf(
|
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). Finish it by cloning the repo and replacing the "+
|
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
|
||||||
"remaining __PROJECT_NAME__ / __MODULE_PATH__ placeholders (in file contents and "+
|
"(same owner/name) once the branch is writable — it safely continues where this left "+
|
||||||
"paths), then pushing; or retry create once the branch settles. Underlying: %s",
|
"off, skipping anything already correct. Underlying: %s",
|
||||||
branch, budget, done, underlying)
|
branch, budget, done, underlying)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +378,8 @@ func infra179FinalizeMessage(branch string, budget, done int, underlying string)
|
|||||||
// 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
|
||||||
@@ -294,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{
|
||||||
@@ -321,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, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,20 +29,36 @@ func templateRepoJSON(name string, isTemplate bool) string {
|
|||||||
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
|
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
|
||||||
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
|
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
|
||||||
type fakeTemplateServer struct {
|
type fakeTemplateServer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
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
|
||||||
puts []string
|
generateCalls int // # times POST .../generate was hit — resume must never increment this
|
||||||
deletes []string
|
puts []string
|
||||||
putBodies map[string]string // path -> decoded written content
|
deletes []string
|
||||||
repoGetsPost int // GET dest after generate (branch fallback)
|
putBodies map[string]string // path -> decoded written content
|
||||||
|
repoGetsPost int // GET dest after generate (branch fallback)
|
||||||
|
|
||||||
|
// dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54).
|
||||||
|
// "" (default) 404s the read, matching a repo that hasn't seeded the file
|
||||||
|
// (a distinct error path); tests that care set it explicitly.
|
||||||
|
dispatchRepos string
|
||||||
|
dispatchReposPuts int
|
||||||
}
|
}
|
||||||
|
|
||||||
func 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)
|
||||||
@@ -135,10 +173,13 @@ func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createOut struct {
|
type createOut struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
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) {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// #46: the infra#179 finalize guidance must not point at a non-existent command
|
// #46: the infra#179 finalize guidance must not point at a non-existent command
|
||||||
// (`hyperguild new-project` was never built). It should name the real remaining
|
// (`hyperguild new-project` was never built). #50 superseded the original
|
||||||
// work — substituting the placeholders — so the caller isn't sent to a dead end.
|
// manual-editing guidance with a concrete, real recovery: re-invoke this same
|
||||||
|
// tool with resume=true.
|
||||||
func TestInfra179FinalizeMessage(t *testing.T) {
|
func TestInfra179FinalizeMessage(t *testing.T) {
|
||||||
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
|
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
|
||||||
|
|
||||||
for _, want := range []string{"infra#179", "__PROJECT_NAME__", "__MODULE_PATH__", "branch does not exist"} {
|
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
|
||||||
if !strings.Contains(msg, want) {
|
if !strings.Contains(msg, want) {
|
||||||
t.Errorf("message missing %q\ngot: %s", want, msg)
|
t.Errorf("message missing %q\ngot: %s", want, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ 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 == "" {
|
||||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
if err := ValidateBranchName(args.Branch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if args.Message == "" {
|
if args.Message == "" {
|
||||||
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
const (
|
||||||
const createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
||||||
const upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
||||||
const getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
||||||
|
getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
||||||
|
)
|
||||||
|
|
||||||
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// foldKey must agree with strings.EqualFold, which is how encoding/json matches.
|
||||||
|
func TestFoldKeyAgreesWithEqualFold(t *testing.T) {
|
||||||
|
pairs := [][2]string{{"branch", "Branch"}, {"sha", "ſha"}, {"k", "K"}, {"ss", "ß"}, {"a", "b"}, {"Σ", "ς"}}
|
||||||
|
for _, p := range pairs {
|
||||||
|
if got, want := foldKey(p[0]) == foldKey(p[1]), strings.EqualFold(p[0], p[1]); got != want {
|
||||||
|
t.Errorf("%q vs %q: foldKey equal=%v, EqualFold=%v", p[0], p[1], got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The duplicate scan is linear in the number of keys: 90k keys (about the
|
||||||
|
// 1 MiB body cap) must scan well under a second.
|
||||||
|
func TestRejectAmbiguousKeysScalesLinearly(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("{")
|
||||||
|
for i := 0; i < 90_000; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `"k%d":1`, i)
|
||||||
|
}
|
||||||
|
b.WriteString("}")
|
||||||
|
var dst struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if err := rejectAmbiguousKeys([]byte(b.String()), &dst); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 2*time.Second {
|
||||||
|
t.Errorf("scan of 90k keys took %v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ 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.Repo, args.Number, "closed")
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ 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.Repo, args.Number)
|
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ 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.Repo, args.Number)
|
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ 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.Repo, args.Number, "open")
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (t *PRCreate) 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
|
||||||
}
|
}
|
||||||
if args.Title == "" {
|
if args.Title == "" {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (t *PRFilesDiff) 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.Number < 1 {
|
if args.Number < 1 {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
|
|||||||
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 {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
|
|||||||
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
|
||||||
}
|
}
|
||||||
state := args.State
|
state := args.State
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (t *PRMerge) 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
|
||||||
}
|
}
|
||||||
if args.Number < 1 {
|
if args.Number < 1 {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ func RegisterAll(
|
|||||||
reg.Register(NewIssueListComments(c, a))
|
reg.Register(NewIssueListComments(c, a))
|
||||||
reg.Register(NewIssueClose(c, a))
|
reg.Register(NewIssueClose(c, a))
|
||||||
reg.Register(NewIssueReopen(c, a))
|
reg.Register(NewIssueReopen(c, a))
|
||||||
|
reg.Register(NewLabelList(c, a))
|
||||||
|
reg.Register(NewIssueLabel(c, a))
|
||||||
reg.Register(NewWorkflowRunList(c, a))
|
reg.Register(NewWorkflowRunList(c, a))
|
||||||
reg.Register(NewReleaseCreate(c, a))
|
reg.Register(NewReleaseCreate(c, a))
|
||||||
reg.Register(NewRepoDelete(c, a))
|
reg.Register(NewRepoDelete(c, a))
|
||||||
|
|||||||
@@ -54,5 +54,5 @@ func TestEveryRegisteredToolIsDispatchable(t *testing.T) {
|
|||||||
// Lock the tool count so an accidental drop of a registration in RegisterAll
|
// Lock the tool count so an accidental drop of a registration in RegisterAll
|
||||||
// (the single source main.go and this test share) fails loudly.
|
// (the single source main.go and this test share) fails loudly.
|
||||||
func TestRegisteredToolCount(t *testing.T) {
|
func TestRegisteredToolCount(t *testing.T) {
|
||||||
assert.Len(t, buildRegistry().Tools(), 39)
|
assert.Len(t, buildRegistry().Tools(), 41)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.Raw
|
|||||||
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
|
||||||
}
|
}
|
||||||
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
|
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (t *RepoCreate) 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
|
||||||
}
|
}
|
||||||
createArgs := gitea.CreateRepoArgs{
|
createArgs := gitea.CreateRepoArgs{
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (t *RepoDelete) 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.Confirm != args.Repo {
|
if args.Confirm != args.Repo {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (t *RepoGet) 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
|
||||||
}
|
}
|
||||||
r, err := t.c.GetRepo(ctx, args.Owner, args.Repo)
|
r, err := t.c.GetRepo(ctx, args.Owner, args.Repo)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (t *RepoList) 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
|
||||||
}
|
}
|
||||||
args.Limit = capLimit(args.Limit, 30)
|
args.Limit = capLimit(args.Limit, 30)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.Ra
|
|||||||
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
|
||||||
}
|
}
|
||||||
switch args.Action {
|
switch args.Action {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
if args.Owner != "" {
|
if args.Owner != "" {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t.a.Check(parts[0]) == nil {
|
if t.a.Check(ctx, parts[0]) == nil {
|
||||||
filtered = append(filtered, r)
|
filtered = append(filtered, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (t *RepoStatus) 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (t *RepoTopicsUpdate) 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
|
||||||
}
|
}
|
||||||
if err := t.c.UpdateTopics(ctx, args.Owner, args.Repo, args.Topics); err != nil {
|
if err := t.c.UpdateTopics(ctx, args.Owner, args.Repo, args.Topics); err != nil {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (t *RepoTree) 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
|
||||||
}
|
}
|
||||||
tree, err := t.c.GetTree(ctx, args.Owner, args.Repo, args.Ref, true)
|
tree, err := t.c.GetTree(ctx, args.Owner, args.Repo, args.Ref, true)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (t *RepoUpdate) 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingServer fails nothing by itself; it records how many requests reached
|
||||||
|
// it so a test can assert that a rejected call never touched Gitea.
|
||||||
|
func countingServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
|
||||||
|
t.Helper()
|
||||||
|
var n atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
n.Add(1)
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// #65 (1): encoding/json matches keys case-insensitively and the last one wins,
|
||||||
|
// so a client-side check reading `branch` could see a different value from the
|
||||||
|
// one the server acts on. Any two keys equal under strings.EqualFold, and any
|
||||||
|
// key that differs from a schema field only in case, are rejected before a
|
||||||
|
// request is made.
|
||||||
|
func TestParseArgsRejectsCaseVariantKeys(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"issue repro: branch + Branch": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","branch":"feat/y8","Branch":"main"}`,
|
||||||
|
"exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`,
|
||||||
|
"case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`,
|
||||||
|
"nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`,
|
||||||
|
// PR #66 review: a number the token scanner cannot parse as float64 must
|
||||||
|
// not make the scan give up and let the duplicate through.
|
||||||
|
"1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`,
|
||||||
|
"-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`,
|
||||||
|
// encoding/json folds like strings.EqualFold: long s (U+017F) == s.
|
||||||
|
"nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`,
|
||||||
|
}
|
||||||
|
for name, raw := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
srv, n := countingServer(t)
|
||||||
|
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(raw))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #65 (2): `refs/heads/main` as a branch makes Gitea create
|
||||||
|
// refs/heads/refs/heads/main, after which every API write to main 500s.
|
||||||
|
// Branch names that can create such refs are rejected before any request.
|
||||||
|
func TestBranchNameValidationRejectsBeforeRequest(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
"refs/heads/main", "refs/tags/v1", "a..b", "feat x", "feat\tx", "feat/",
|
||||||
|
"/feat", "-feat", ".feat", "feat//x", "feat/.x", "feat.lock", "feat.", "HEAD", "feat@{1}", "",
|
||||||
|
}
|
||||||
|
for _, b := range bad {
|
||||||
|
t.Run("file_write_branch "+b, func(t *testing.T) {
|
||||||
|
srv, n := countingServer(t)
|
||||||
|
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||||
|
args, _ := json.Marshal(map[string]string{
|
||||||
|
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
||||||
|
})
|
||||||
|
_, err := tool.Call(context.Background(), args)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||||
|
})
|
||||||
|
if b == "" {
|
||||||
|
continue // tbd_ship derives a branch when empty
|
||||||
|
}
|
||||||
|
t.Run("tbd_ship "+b, func(t *testing.T) {
|
||||||
|
srv, n := countingServer(t)
|
||||||
|
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||||
|
args, _ := json.Marshal(map[string]string{
|
||||||
|
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
||||||
|
})
|
||||||
|
_, err := tool.Call(context.Background(), args)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tbd_ship's base is the branch-protection probe key: `refs/heads/main` would
|
||||||
|
// read as unprotected, so it is held to the same rule as the head branch.
|
||||||
|
func TestTBDShipRejectsRefsPrefixedBase(t *testing.T) {
|
||||||
|
srv, n := countingServer(t)
|
||||||
|
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","base":"refs/heads/main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchNameValidationAcceptsOrdinaryNames(t *testing.T) {
|
||||||
|
for _, b := range []string{"main", "feat/y8", "tbd/fix-abc-1234abcd", "hold/mail-chat-infra-21", "v1.2_x"} {
|
||||||
|
t.Run(b, func(t *testing.T) {
|
||||||
|
assert.NoError(t, tools.ValidateBranchName(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ func (t *TagCreate) 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.Tag == "" {
|
if args.Tag == "" {
|
||||||
|
|||||||
+103
-7
@@ -36,8 +36,8 @@ const (
|
|||||||
|
|
||||||
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "tbd_ship",
|
Name: "tbd_ship",
|
||||||
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, reason, branch}.",
|
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green AND the named issue carries exactly one risk/ label, risk/low. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when no issue is named or its risk/ label is missing, ambiguous, unknown, risk/medium or risk/high, when the issue cannot be read, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, risk, reason, branch}.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
@@ -50,6 +50,7 @@ func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
|||||||
"branch":{"type":"string","description":"Short-lived branch name. Default derived: tbd/<slug>-<hash>."},
|
"branch":{"type":"string","description":"Short-lived branch name. Default derived: tbd/<slug>-<hash>."},
|
||||||
"pr_title":{"type":"string","description":"PR title. Default: the commit message."},
|
"pr_title":{"type":"string","description":"PR title. Default: the commit message."},
|
||||||
"pr_body":{"type":"string"},
|
"pr_body":{"type":"string"},
|
||||||
|
"issue":{"type":"integer","minimum":1,"description":"Issue number in the same repo whose risk/ label classifies this change. Auto-merge happens only for exactly one label, risk/low; no issue, none, several, an unknown value, risk/medium or risk/high means PR only, never merged (unclassified is not low)."},
|
||||||
"delete_branch":{"type":"boolean","description":"Delete the branch after a successful merge. Default true."},
|
"delete_branch":{"type":"boolean","description":"Delete the branch after a successful merge. Default true."},
|
||||||
"ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."}
|
"ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."}
|
||||||
},
|
},
|
||||||
@@ -68,10 +69,76 @@ type tbdShipArgs struct {
|
|||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
PRTitle string `json:"pr_title"`
|
PRTitle string `json:"pr_title"`
|
||||||
PRBody string `json:"pr_body"`
|
PRBody string `json:"pr_body"`
|
||||||
|
Issue int `json:"issue"`
|
||||||
DeleteBranch *bool `json:"delete_branch"`
|
DeleteBranch *bool `json:"delete_branch"`
|
||||||
CITimeoutSec int `json:"ci_timeout_seconds"`
|
CITimeoutSec int `json:"ci_timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// riskTier is the change's classification under the lane model (local-dev .context/AGENT.md rule 5): only a change
|
||||||
|
// whose issue carries exactly one risk/ label, risk/low, may merge on green; everything else is reviewed by a human
|
||||||
|
// (gitea-mcp#63). The zero value is deliberately NOT riskLow: a forgotten field must never mean "low".
|
||||||
|
type riskTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
riskLow riskTier = "low"
|
||||||
|
riskMedium riskTier = "medium"
|
||||||
|
riskHigh riskTier = "high"
|
||||||
|
riskNone riskTier = "none" // no issue named, or the issue has no risk/ label: unclassified is not low
|
||||||
|
riskAmbiguous riskTier = "ambiguous" // more than one risk/ label
|
||||||
|
riskUnknown riskTier = "unknown" // risk/<something else>
|
||||||
|
riskUnreadable riskTier = "unreadable" // the issue lookup failed
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifyRisk reads the tier from an issue's labels. Exact, case-sensitive prefix "risk/"; exactly one such label
|
||||||
|
// is required, so two labels (even identical ones) are ambiguous rather than "the lower one wins".
|
||||||
|
func classifyRisk(labels []gitea.Label) riskTier {
|
||||||
|
var found []string
|
||||||
|
for _, l := range labels {
|
||||||
|
if strings.HasPrefix(l.Name, "risk/") {
|
||||||
|
found = append(found, strings.TrimPrefix(l.Name, "risk/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(found) {
|
||||||
|
case 0:
|
||||||
|
return riskNone
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return riskAmbiguous
|
||||||
|
}
|
||||||
|
switch found[0] {
|
||||||
|
case "low":
|
||||||
|
return riskLow
|
||||||
|
case "medium":
|
||||||
|
return riskMedium
|
||||||
|
case "high":
|
||||||
|
return riskHigh
|
||||||
|
default:
|
||||||
|
return riskUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR opened, not merged"
|
||||||
|
|
||||||
|
// riskBlockReason explains why a tier other than riskLow blocks the merge.
|
||||||
|
func riskBlockReason(r riskTier) string {
|
||||||
|
switch r {
|
||||||
|
case riskMedium, riskHigh:
|
||||||
|
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR opened, not merged", r)
|
||||||
|
case riskAmbiguous:
|
||||||
|
return "the issue carries more than one risk/ label — ambiguous classification; PR opened, not merged"
|
||||||
|
case riskUnknown:
|
||||||
|
return "the issue carries an unknown risk/ value — PR opened, not merged"
|
||||||
|
case riskUnreadable:
|
||||||
|
return "could not read the issue's labels — fail-closed; PR opened, not merged"
|
||||||
|
case riskLow:
|
||||||
|
return "" // never asked: only a non-low tier needs explaining
|
||||||
|
case riskNone:
|
||||||
|
return unclassifiedReason
|
||||||
|
default: // the zero value and any tier added later: unclassified is not low
|
||||||
|
return unclassifiedReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// shipGate is the decision produced by evaluateShipGate.
|
// shipGate is the decision produced by evaluateShipGate.
|
||||||
type shipGate struct {
|
type shipGate struct {
|
||||||
merge bool
|
merge bool
|
||||||
@@ -101,9 +168,10 @@ func classifyCI(runs []gitea.WorkflowRun) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evaluateShipGate is the load-bearing safety of tbd_ship. It returns merge=true
|
// evaluateShipGate is the load-bearing safety of tbd_ship. It returns merge=true
|
||||||
// only when CI is fully green AND the base branch is not review-protected. Every
|
// only when CI is fully green AND the base branch is not review-protected AND the
|
||||||
// other state fails closed to PR-only with an explanatory reason (#40).
|
// change is classified risk/low. Every other state fails closed to PR-only with an
|
||||||
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection) shipGate {
|
// explanatory reason (#40, gitea-mcp#63).
|
||||||
|
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
|
||||||
ci := classifyCI(runs)
|
ci := classifyCI(runs)
|
||||||
|
|
||||||
// Review-protected base can't be auto-merged regardless of CI.
|
// Review-protected base can't be auto-merged regardless of CI.
|
||||||
@@ -112,6 +180,11 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection) ship
|
|||||||
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)}
|
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI".
|
||||||
|
if risk != riskLow {
|
||||||
|
return shipGate{false, ci, riskBlockReason(risk)}
|
||||||
|
}
|
||||||
|
|
||||||
switch ci {
|
switch ci {
|
||||||
case "success":
|
case "success":
|
||||||
return shipGate{true, ci, ""}
|
return shipGate{true, ci, ""}
|
||||||
@@ -145,7 +218,7 @@ func (t *TBDShip) 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
|
||||||
}
|
}
|
||||||
if args.Path == "" || args.Content == "" || args.Message == "" {
|
if args.Path == "" || args.Content == "" || args.Message == "" {
|
||||||
@@ -160,6 +233,13 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
if branch == "" {
|
if branch == "" {
|
||||||
branch = deriveBranch(args.Message, args.Path, args.Content)
|
branch = deriveBranch(args.Message, args.Path, args.Content)
|
||||||
}
|
}
|
||||||
|
// Both names reach a ref: base is also the protection-probe key, so a
|
||||||
|
// `refs/heads/main` base would read as unprotected (#65).
|
||||||
|
for _, b := range []string{base, branch} {
|
||||||
|
if err := ValidateBranchName(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
deleteBranch := true
|
deleteBranch := true
|
||||||
if args.DeleteBranch != nil {
|
if args.DeleteBranch != nil {
|
||||||
deleteBranch = *args.DeleteBranch
|
deleteBranch = *args.DeleteBranch
|
||||||
@@ -223,7 +303,8 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
|
|
||||||
// CI gate on the PR head commit.
|
// CI gate on the PR head commit.
|
||||||
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
|
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
|
||||||
gate := evaluateShipGate(runs, bp)
|
risk := t.riskTierOf(ctx, args.Owner, args.Repo, args.Issue)
|
||||||
|
gate := evaluateShipGate(runs, bp, risk)
|
||||||
|
|
||||||
result := map[string]any{
|
result := map[string]any{
|
||||||
"merged": false,
|
"merged": false,
|
||||||
@@ -231,6 +312,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
"pr_url": pr.HTMLURL,
|
"pr_url": pr.HTMLURL,
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
"ci_status": gate.ciStatus,
|
"ci_status": gate.ciStatus,
|
||||||
|
"risk": string(risk),
|
||||||
}
|
}
|
||||||
if !gate.merge {
|
if !gate.merge {
|
||||||
result["reason"] = gate.reason
|
result["reason"] = gate.reason
|
||||||
@@ -254,6 +336,20 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
return textOK(result)
|
return textOK(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// riskTierOf classifies the change from the named issue. No issue, or an issue that cannot be read, never yields
|
||||||
|
// riskLow. The link between the PR and the issue is the caller's claim: the gate enforces the tier of the issue it
|
||||||
|
// is given, it cannot prove that issue is the one the change belongs to (stated limit, gitea-mcp#63).
|
||||||
|
func (t *TBDShip) riskTierOf(ctx context.Context, owner, repo string, issue int) riskTier {
|
||||||
|
if issue <= 0 {
|
||||||
|
return riskNone
|
||||||
|
}
|
||||||
|
iss, err := t.c.GetIssue(ctx, owner, repo, issue)
|
||||||
|
if err != nil || iss == nil {
|
||||||
|
return riskUnreadable
|
||||||
|
}
|
||||||
|
return classifyRisk(iss.Labels)
|
||||||
|
}
|
||||||
|
|
||||||
// pollRuns lists the head commit's workflow runs, polling up to timeoutSec for
|
// pollRuns lists the head commit's workflow runs, polling up to timeoutSec for
|
||||||
// them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
|
// them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
|
||||||
// sleep). A listing error yields no runs → the gate fails closed.
|
// sleep). A listing error yields no runs → the gate fails closed.
|
||||||
|
|||||||
@@ -20,22 +20,31 @@ func TestEvaluateShipGate(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
runs []gitea.WorkflowRun
|
runs []gitea.WorkflowRun
|
||||||
bp *gitea.BranchProtection
|
bp *gitea.BranchProtection
|
||||||
|
risk riskTier
|
||||||
wantMerge bool
|
wantMerge bool
|
||||||
wantCI string
|
wantCI string
|
||||||
}{
|
}{
|
||||||
{"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, true, "success"},
|
{"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskLow, true, "success"},
|
||||||
{"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, false, "pending"},
|
{"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, riskLow, false, "pending"},
|
||||||
{"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, false, "pending"},
|
{"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, riskLow, false, "pending"},
|
||||||
{"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, false, "failed"},
|
{"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, riskLow, false, "failed"},
|
||||||
{"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, false, "failed"},
|
{"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, riskLow, false, "failed"},
|
||||||
{"no runs → no merge (no CI gate)", nil, open, false, "none"},
|
{"no runs → no merge (no CI gate)", nil, open, riskLow, false, "none"},
|
||||||
{"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, false, "pending"},
|
{"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, riskLow, false, "pending"},
|
||||||
{"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, false, "success"},
|
{"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, riskLow, false, "success"},
|
||||||
{"no runs + protected → no merge", nil, protected, false, "none"},
|
{"no runs + protected → no merge", nil, protected, riskLow, false, "none"},
|
||||||
|
// gitea-mcp#63: the tier is an input. Green CI never overrides it; unclassified is not low.
|
||||||
|
{"green + risk/medium → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskMedium, false, "success"},
|
||||||
|
{"green + risk/high → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskHigh, false, "success"},
|
||||||
|
{"green + no risk label → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskNone, false, "success"},
|
||||||
|
{"green + ambiguous labels → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskAmbiguous, false, "success"},
|
||||||
|
{"green + unknown value → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnknown, false, "success"},
|
||||||
|
{"green + unreadable issue → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnreadable, false, "success"},
|
||||||
|
{"green + the zero tier → no merge (a forgotten field must not mean low)", []gitea.WorkflowRun{run("completed", "success")}, open, riskTier(""), false, "success"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
g := evaluateShipGate(tc.runs, tc.bp)
|
g := evaluateShipGate(tc.runs, tc.bp, tc.risk)
|
||||||
if g.merge != tc.wantMerge {
|
if g.merge != tc.wantMerge {
|
||||||
t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason)
|
t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason)
|
||||||
}
|
}
|
||||||
@@ -51,3 +60,41 @@ func TestEvaluateShipGate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClassifyRisk(t *testing.T) {
|
||||||
|
l := func(names ...string) []gitea.Label {
|
||||||
|
out := make([]gitea.Label, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
out[i] = gitea.Label{Name: n}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
labels []gitea.Label
|
||||||
|
want riskTier
|
||||||
|
}{
|
||||||
|
{"low", l("risk/low"), riskLow},
|
||||||
|
{"medium", l("risk/medium"), riskMedium},
|
||||||
|
{"high", l("risk/high"), riskHigh},
|
||||||
|
{"low among others", l("type/fix", "risk/low", "size/s"), riskLow},
|
||||||
|
{"none", l("type/fix"), riskNone},
|
||||||
|
{"no labels", nil, riskNone},
|
||||||
|
{"two different", l("risk/low", "risk/high"), riskAmbiguous},
|
||||||
|
{"two identical", l("risk/low", "risk/low"), riskAmbiguous},
|
||||||
|
{"unknown value", l("risk/extreme"), riskUnknown},
|
||||||
|
{"empty value", l("risk/"), riskUnknown},
|
||||||
|
{"wrong case", l("Risk/Low"), riskNone},
|
||||||
|
{"value case matters: risk/Low is unknown, not low", l("risk/Low"), riskUnknown},
|
||||||
|
{"trailing space in the value is unknown, not low", l("risk/low "), riskUnknown},
|
||||||
|
{"leading space in the value is unknown, not low", l("risk/ low"), riskUnknown},
|
||||||
|
{"prefix only inside a name", l("not-risk/low"), riskNone},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := classifyRisk(tc.labels); got != tc.want {
|
||||||
|
t.Errorf("classifyRisk(%v) = %q, want %q", tc.labels, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ func shipFake(t *testing.T, runsJSON string, merged, deleted *atomic.Bool) *http
|
|||||||
// shipFakeOpts adds protection (requiredApprovals>0 → protected) and a merge
|
// shipFakeOpts adds protection (requiredApprovals>0 → protected) and a merge
|
||||||
// status code, so tests can drive the review-protected and conflict paths.
|
// status code, so tests can drive the review-protected and conflict paths.
|
||||||
func shipFakeOpts(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, merged, deleted *atomic.Bool) *httptest.Server {
|
func shipFakeOpts(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, merged, deleted *atomic.Bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return shipFakeFull(t, runsJSON, requiredApprovals, mergeStatus, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, merged, deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shipFakeFull also serves the issue whose risk/ label classifies the change (gitea-mcp#63): issueLabels is the
|
||||||
|
// labels JSON array, issueStatus the HTTP status of the issue lookup.
|
||||||
|
func shipFakeFull(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, issueLabels string, issueStatus int, merged, deleted *atomic.Bool) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := r.URL.Path
|
p := r.URL.Path
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(p, "/issues/42"):
|
||||||
|
w.WriteHeader(issueStatus)
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":` + issueLabels + `}`))
|
||||||
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
|
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
|
||||||
if requiredApprovals > 0 {
|
if requiredApprovals > 0 {
|
||||||
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(requiredApprovals) + `}`))
|
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(requiredApprovals) + `}`))
|
||||||
@@ -83,7 +93,7 @@ func TestTBDShip_GreenCI_Merges(t *testing.T) {
|
|||||||
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, &merged, &deleted)
|
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, &merged, &deleted)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
|
||||||
|
|
||||||
assert.Equal(t, true, res["merged"])
|
assert.Equal(t, true, res["merged"])
|
||||||
assert.Equal(t, "success", res["ci_status"])
|
assert.Equal(t, "success", res["ci_status"])
|
||||||
@@ -98,7 +108,7 @@ func TestTBDShip_NoCI_FailsClosed(t *testing.T) {
|
|||||||
srv := shipFake(t, `[]`, &merged, &deleted)
|
srv := shipFake(t, `[]`, &merged, &deleted)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
|
||||||
|
|
||||||
assert.Equal(t, false, res["merged"])
|
assert.Equal(t, false, res["merged"])
|
||||||
assert.Equal(t, "none", res["ci_status"])
|
assert.Equal(t, "none", res["ci_status"])
|
||||||
@@ -113,7 +123,7 @@ func TestTBDShip_RedCI_FailsClosed(t *testing.T) {
|
|||||||
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, &merged, &deleted)
|
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, &merged, &deleted)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
|
||||||
|
|
||||||
assert.Equal(t, false, res["merged"])
|
assert.Equal(t, false, res["merged"])
|
||||||
assert.Equal(t, "failed", res["ci_status"])
|
assert.Equal(t, "failed", res["ci_status"])
|
||||||
@@ -128,7 +138,7 @@ func TestTBDShip_ReviewProtected_FailsClosed(t *testing.T) {
|
|||||||
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 1, http.StatusOK, &merged, &deleted)
|
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 1, http.StatusOK, &merged, &deleted)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
|
||||||
|
|
||||||
assert.Equal(t, false, res["merged"])
|
assert.Equal(t, false, res["merged"])
|
||||||
assert.NotEmpty(t, res["reason"])
|
assert.NotEmpty(t, res["reason"])
|
||||||
@@ -142,7 +152,7 @@ func TestTBDShip_MergeConflict_FailsClosed(t *testing.T) {
|
|||||||
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 0, http.StatusConflict, &merged, &deleted)
|
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 0, http.StatusConflict, &merged, &deleted)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
|
||||||
|
|
||||||
assert.Equal(t, false, res["merged"])
|
assert.Equal(t, false, res["merged"])
|
||||||
assert.NotEmpty(t, res["reason"])
|
assert.NotEmpty(t, res["reason"])
|
||||||
@@ -160,6 +170,8 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
|
|||||||
p := r.URL.Path
|
p := r.URL.Path
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(p, "/issues/42"):
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":[{"id":1,"name":"risk/low"}]}`))
|
||||||
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
|
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
@@ -196,7 +208,7 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","branch":"`+branch+`"}`)
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42,"branch":"`+branch+`"}`)
|
||||||
|
|
||||||
assert.Equal(t, true, res["merged"], "resume must merge when CI is green")
|
assert.Equal(t, true, res["merged"], "resume must merge when CI is green")
|
||||||
assert.Equal(t, float64(7), res["pr_number"], "must reuse the existing PR #7")
|
assert.Equal(t, float64(7), res["pr_number"], "must reuse the existing PR #7")
|
||||||
@@ -204,6 +216,103 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
|
|||||||
assert.True(t, merged.Load())
|
assert.True(t, merged.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- gitea-mcp#63: the risk tier is an input to the gate. Only exactly one risk/low label merges. ----
|
||||||
|
const (
|
||||||
|
shipGreen = `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`
|
||||||
|
shipArgs = `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTBDShip_RiskTier_OnlyLowMerges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
labels string
|
||||||
|
wantMerged bool
|
||||||
|
wantInReason string
|
||||||
|
}{
|
||||||
|
{"risk/low merges", `[{"id":1,"name":"risk/low"}]`, true, ""},
|
||||||
|
{"risk/low among other labels merges", `[{"id":2,"name":"type/fix"},{"id":1,"name":"risk/low"},{"id":3,"name":"size/s"}]`, true, ""},
|
||||||
|
{"risk/medium does not merge", `[{"id":1,"name":"risk/medium"}]`, false, "risk/medium"},
|
||||||
|
{"risk/high does not merge", `[{"id":1,"name":"risk/high"}]`, false, "risk/high"},
|
||||||
|
{"no risk label is unclassified, not low", `[{"id":2,"name":"type/fix"}]`, false, "no risk/"},
|
||||||
|
{"no labels at all", `[]`, false, "no risk/"},
|
||||||
|
{"two risk labels are ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/high"}]`, false, "more than one"},
|
||||||
|
{"two identical risk/low labels are still ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/low"}]`, false, "more than one"},
|
||||||
|
{"an unknown risk value does not merge", `[{"id":1,"name":"risk/lowish"}]`, false, "unknown"},
|
||||||
|
{"an empty risk value does not merge", `[{"id":1,"name":"risk/"}]`, false, "unknown"},
|
||||||
|
{"labels are case-sensitive: RISK/LOW is not risk/low", `[{"id":1,"name":"RISK/LOW"}]`, false, "no risk/"},
|
||||||
|
{"a label that merely contains risk/low does not count", `[{"id":1,"name":"not-risk/low"}]`, false, "no risk/"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var merged, deleted atomic.Bool
|
||||||
|
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, tc.labels, http.StatusOK, &merged, &deleted)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res := callShip(t, srv.URL, shipArgs)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.wantMerged, res["merged"])
|
||||||
|
assert.Equal(t, tc.wantMerged, merged.Load(), "the merge endpoint is called iff the tier allows it")
|
||||||
|
assert.Equal(t, "success", res["ci_status"], "CI was green: the tier alone decided")
|
||||||
|
assert.Equal(t, float64(7), res["pr_number"], "the PR is opened either way")
|
||||||
|
if !tc.wantMerged {
|
||||||
|
assert.Contains(t, res["reason"], tc.wantInReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No issue named → nothing classifies the change → no merge, even on green CI.
|
||||||
|
func TestTBDShip_NoIssue_Unclassified_NoMerge(t *testing.T) {
|
||||||
|
var merged, deleted atomic.Bool
|
||||||
|
srv := shipFake(t, shipGreen, &merged, &deleted)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, false, res["merged"])
|
||||||
|
assert.Contains(t, res["reason"], "issue")
|
||||||
|
assert.False(t, merged.Load(), "an unclassified change must not merge")
|
||||||
|
assert.Equal(t, float64(7), res["pr_number"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The issue lookup failing is not a reason to merge: fail closed, PR left open.
|
||||||
|
func TestTBDShip_IssueLookupFails_FailsClosed(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusInternalServerError, http.StatusNotFound, http.StatusForbidden} {
|
||||||
|
var merged, deleted atomic.Bool
|
||||||
|
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, status, &merged, &deleted)
|
||||||
|
|
||||||
|
res := callShip(t, srv.URL, shipArgs)
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, false, res["merged"], "status %d", status)
|
||||||
|
assert.NotEmpty(t, res["reason"], "status %d", status)
|
||||||
|
assert.False(t, merged.Load(), "status %d: an unreadable issue must not merge", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// risk/low is necessary, not sufficient: red CI still blocks it, and a review-protected base still blocks it.
|
||||||
|
func TestTBDShip_RiskLow_DoesNotOverrideTheOtherGates(t *testing.T) {
|
||||||
|
var merged, deleted atomic.Bool
|
||||||
|
red := shipFakeFull(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, 0, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, &merged, &deleted)
|
||||||
|
defer red.Close()
|
||||||
|
assert.Equal(t, false, callShip(t, red.URL, shipArgs)["merged"])
|
||||||
|
assert.False(t, merged.Load())
|
||||||
|
|
||||||
|
prot := shipFakeFull(t, shipGreen, 1, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, &merged, &deleted)
|
||||||
|
defer prot.Close()
|
||||||
|
assert.Equal(t, false, callShip(t, prot.URL, shipArgs)["merged"])
|
||||||
|
assert.False(t, merged.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decision is visible to the caller.
|
||||||
|
func TestTBDShip_ReportsTheRiskTier(t *testing.T) {
|
||||||
|
var merged, deleted atomic.Bool
|
||||||
|
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, `[{"id":1,"name":"risk/medium"}]`, http.StatusOK, &merged, &deleted)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, "medium", callShip(t, srv.URL, shipArgs)["risk"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestTBDShip_AllowlistRejects(t *testing.T) {
|
func TestTBDShip_AllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewTBDShip(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
tool := tools.NewTBDShip(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","path":"p","content":"c","message":"m"}`))
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","path":"p","content":"c","message":"m"}`))
|
||||||
|
|||||||
+149
-3
@@ -1,10 +1,14 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
@@ -22,12 +26,154 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
if err := json.Unmarshal([]byte("{}"), dst); err != nil {
|
if err := json.Unmarshal([]byte("{}"), dst); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
|
} else {
|
||||||
return err
|
// Reject ambiguity BEFORE alias normalisation: re-marshalling through a
|
||||||
|
// map would otherwise silently collapse exact duplicates (#65).
|
||||||
|
if err := rejectAmbiguousKeys(raw, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return validateRequiredIdentifiers(dst)
|
return validateRequiredIdentifiers(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rejectAmbiguousKeys refuses input whose meaning depends on encoding/json's
|
||||||
|
// case-insensitive, last-key-wins matching (#65). A client-side check that reads
|
||||||
|
// `branch` must see the value the server acts on, so: no object at any depth may
|
||||||
|
// carry two keys equal under strings.EqualFold (exact duplicates included), and
|
||||||
|
// no top-level key may differ from a field of dst only in case.
|
||||||
|
func rejectAmbiguousKeys(raw json.RawMessage, dst any) error {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
// UseNumber: without it Token() fails on a number outside float64 range,
|
||||||
|
// which json.Unmarshal ignores in an unknown key — a decoy that would end
|
||||||
|
// the scan before the duplicate (PR #66 review).
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := scanJSONValue(dec); err != nil {
|
||||||
|
if errors.Is(err, gitea.ErrValidation) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Fail closed: input the scanner cannot read is input it cannot vouch for.
|
||||||
|
return fmt.Errorf("arguments are not valid JSON: %v: %w", err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
var top map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &top); err != nil {
|
||||||
|
return nil // not an object; nothing to compare against the schema
|
||||||
|
}
|
||||||
|
fields := jsonFieldNames(dst)
|
||||||
|
for k := range top {
|
||||||
|
for _, f := range fields {
|
||||||
|
if k != f && strings.EqualFold(k, f) {
|
||||||
|
return fmt.Errorf("argument %q does not match field %q exactly (keys are case-sensitive): %w", k, f, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanJSONValue consumes one JSON value from dec, failing with ErrValidation if
|
||||||
|
// any object within it has two keys that are equal under strings.EqualFold.
|
||||||
|
func scanJSONValue(dec *json.Decoder) error {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, ok := tok.(json.Delim)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch d {
|
||||||
|
case '{':
|
||||||
|
seen := map[string]string{}
|
||||||
|
for dec.More() {
|
||||||
|
kt, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k, _ := kt.(string)
|
||||||
|
fk := foldKey(k)
|
||||||
|
if prev, dup := seen[fk]; dup {
|
||||||
|
return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
seen[fk] = k
|
||||||
|
if err := scanJSONValue(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
for dec.More() {
|
||||||
|
if err := scanJSONValue(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = dec.Token() // closing delimiter
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldKey maps every rune to the smallest member of its unicode.SimpleFold
|
||||||
|
// orbit, so foldKey(a) == foldKey(b) exactly when strings.EqualFold(a, b) —
|
||||||
|
// the folding encoding/json uses (long s == s, Kelvin sign == k). A map keyed
|
||||||
|
// on it keeps the duplicate scan linear (PR #66 review: a pairwise EqualFold
|
||||||
|
// loop cost 7s of CPU on ~90k keys).
|
||||||
|
func foldKey(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
min := r
|
||||||
|
for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
|
||||||
|
if f < min {
|
||||||
|
min = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(min)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonFieldNames returns the json tag names of dst's top-level struct fields.
|
||||||
|
func jsonFieldNames(dst any) []string {
|
||||||
|
v := reflect.ValueOf(dst)
|
||||||
|
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := v.Elem().Type()
|
||||||
|
names := make([]string, 0, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
||||||
|
if name == "" || name == "-" {
|
||||||
|
name = t.Field(i).Name
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchNameRe is the shape the skills mcp-destructive-guard hook enforces.
|
||||||
|
var branchNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||||
|
|
||||||
|
// ValidateBranchName rejects branch names that Gitea would turn into a ref
|
||||||
|
// other than refs/heads/<name> as the caller meant it. `refs/heads/main` in
|
||||||
|
// particular creates refs/heads/refs/heads/main, after which every API write to
|
||||||
|
// main fails with "matches more than one" until the ref is deleted by hand
|
||||||
|
// (#65). Beyond the shape regexp it applies git's own check-ref-format rules
|
||||||
|
// that the regexp admits.
|
||||||
|
func ValidateBranchName(b string) error {
|
||||||
|
bad := !branchNameRe.MatchString(b) ||
|
||||||
|
strings.HasPrefix(b, "refs/") ||
|
||||||
|
b == "HEAD" ||
|
||||||
|
strings.Contains(b, "..") ||
|
||||||
|
strings.Contains(b, "//") ||
|
||||||
|
strings.Contains(b, "/.") ||
|
||||||
|
strings.HasSuffix(b, "/") ||
|
||||||
|
strings.HasSuffix(b, ".") ||
|
||||||
|
strings.HasSuffix(b, ".lock")
|
||||||
|
if bad {
|
||||||
|
return fmt.Errorf("invalid branch name %q (want a short name like feat/x, no refs/ prefix): %w", b, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateRequiredIdentifiers rejects an empty repo identifier at the tool layer
|
// validateRequiredIdentifiers rejects an empty repo identifier at the tool layer
|
||||||
// before it reaches the client, where it would build a trailing-empty path
|
// before it reaches the client, where it would build a trailing-empty path
|
||||||
// segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and
|
// segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and
|
||||||
@@ -36,7 +182,7 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
// allowlist check. Returns a typed ErrValidation naming the missing field.
|
// allowlist check. Returns a typed ErrValidation naming the missing field.
|
||||||
func validateRequiredIdentifiers(dst any) error {
|
func validateRequiredIdentifiers(dst any) error {
|
||||||
v := reflect.ValueOf(dst)
|
v := reflect.ValueOf(dst)
|
||||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v = v.Elem()
|
v = v.Elem()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (t *WorkflowRunList) 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
|
||||||
}
|
}
|
||||||
args.Limit = capLimit(args.Limit, 10)
|
args.Limit = capLimit(args.Limit, 10)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user