Compare commits
57
Commits
4122e1f2ae
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82823aad1e | ||
|
|
64176fe6d7 | ||
|
|
6d344c74a8 | ||
|
|
a515f53731 | ||
|
|
f0527c94bc | ||
|
|
c40a9d9003 | ||
|
|
8ebad95adf | ||
|
|
169040c073 | ||
|
|
834a994af9 | ||
|
|
0a12e905c9 | ||
|
|
72ba89be63 | ||
|
|
9b7f53bdda | ||
|
|
4c1f36f9eb | ||
|
|
99586984bf | ||
|
|
e0bede0547 | ||
|
|
9f12db3d94 | ||
|
|
93c32edbb5 | ||
|
|
4ebea7d023 | ||
|
|
711dc46e5e | ||
|
|
039598855c | ||
|
|
d45ba712ce | ||
|
|
4d658004ae | ||
|
|
3329ff3088 | ||
|
|
2ebaee8d03 | ||
|
|
e30951ad72 | ||
|
|
e3cdd23260 | ||
|
|
184d5a95dd | ||
|
|
e2594f45f2 | ||
|
|
f99a8b38cb | ||
|
|
8a751741a4 | ||
|
|
668e8fa28d | ||
|
|
3c1ca7d3db | ||
|
|
8bea0d2f27 | ||
|
|
24c353383f | ||
|
|
be85baf410 | ||
|
|
658f4ba84f | ||
|
|
60212fc5d2 | ||
|
|
dc907fb7e0 | ||
|
|
c4bd3396c4 | ||
|
|
11f86f5d99 | ||
|
|
f7076c9ac8 | ||
|
|
e31fd3f023 | ||
|
|
3cccbfb8cb | ||
|
|
3648373333 | ||
|
|
eeefc626ed | ||
|
|
5545d6ab4b | ||
|
|
9013c8ff9c | ||
|
|
f26f922c96 | ||
|
|
a414222610 | ||
|
|
3b490271ef | ||
|
|
d4dddbdb6c | ||
|
|
a69d3a8b76 | ||
|
|
5f3ad99122 | ||
|
|
2c94de7b59 | ||
|
|
e2da495581 | ||
|
|
7178ae32be | ||
|
|
cb4f0caf0b |
+160
-27
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -36,9 +44,34 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
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
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
PR. Do not merge without explicit instruction from Mathias.
|
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
|
## Default stack
|
||||||
|
|
||||||
@@ -49,9 +82,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -61,9 +95,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **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:`), one concern per PR, PR describes *why* not *what*
|
- **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
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
@@ -71,7 +128,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| 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 | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -103,18 +160,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
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.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **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
|
## Client work rules
|
||||||
|
|
||||||
@@ -160,15 +263,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
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.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,7 +288,7 @@ Key skills:
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -211,8 +316,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -250,4 +358,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
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.
|
||||||
|
|||||||
+32
-4
@@ -9,7 +9,7 @@
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -37,8 +37,11 @@
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -76,4 +79,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
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.
|
||||||
|
|||||||
+160
-27
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -41,9 +49,34 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
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
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
PR. Do not merge without explicit instruction from Mathias.
|
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
|
## Default stack
|
||||||
|
|
||||||
@@ -54,9 +87,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -66,9 +100,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **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:`), one concern per PR, PR describes *why* not *what*
|
- **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
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
@@ -76,7 +133,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| 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 | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -108,18 +165,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
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.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **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
|
## Client work rules
|
||||||
|
|
||||||
@@ -165,15 +268,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
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.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -188,7 +293,7 @@ Key skills:
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -216,8 +321,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -255,6 +363,31 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+160
-27
@@ -30,6 +30,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -39,9 +47,34 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
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
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
PR. Do not merge without explicit instruction from Mathias.
|
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
|
## Default stack
|
||||||
|
|
||||||
@@ -52,9 +85,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -64,9 +98,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **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:`), one concern per PR, PR describes *why* not *what*
|
- **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
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
@@ -74,7 +131,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| 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 | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -106,18 +163,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
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.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **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
|
## Client work rules
|
||||||
|
|
||||||
@@ -163,15 +266,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
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.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,7 +291,7 @@ Key skills:
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -214,8 +319,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -253,4 +361,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
6. Commit directly to main. Run `task check` before every push. Never create
|
||||||
|
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||||
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
|
|
||||||
|
### What shipped
|
||||||
|
|
||||||
|
| Tag | PR | Tools / fixes |
|
||||||
|
|-----|----|---------------|
|
||||||
|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||||
|
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||||
|
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||||
|
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||||
|
|
||||||
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
|
|
||||||
|
### Next up
|
||||||
|
|
||||||
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
|
|
||||||
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
|
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
name: CD
|
name: CD
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea-mcp
|
IMAGE: gitea-mcp
|
||||||
@@ -43,7 +41,6 @@ jobs:
|
|||||||
name: Build & Import
|
name: Build & Import
|
||||||
needs: check
|
needs: check
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
outputs:
|
outputs:
|
||||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||||
steps:
|
steps:
|
||||||
@@ -63,7 +60,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
REGISTRY="localhost:5000"
|
REGISTRY="localhost:5000"
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||||
|
# Stamp the real build version: the git tag on a v* build, else the short sha.
|
||||||
|
VERSION="${{ steps.meta.outputs.version-tag }}"
|
||||||
|
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
|
||||||
buildah build \
|
buildah build \
|
||||||
|
--build-arg VERSION="${VERSION}" \
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
||||||
-t ${REF} \
|
-t ${REF} \
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
echo "→ Running task check before push..."
|
||||||
|
task check
|
||||||
|
echo "✓ pre-push check passed"
|
||||||
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -36,9 +44,34 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
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
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
PR. Do not merge without explicit instruction from Mathias.
|
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
|
## Default stack
|
||||||
|
|
||||||
@@ -49,9 +82,10 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Build | Task (taskfile.dev) | Make | — |
|
| Build | Task (taskfile.dev) | Make | — |
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | Qdrant (vector), BM25 | — | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -61,9 +95,32 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **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:`), one concern per PR, PR describes *why* not *what*
|
- **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
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **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
|
## Infrastructure
|
||||||
|
|
||||||
@@ -71,7 +128,7 @@ Three machines on Tailscale:
|
|||||||
|
|
||||||
| Machine | Role | Key specs |
|
| Machine | Role | Key specs |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
| 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 | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
@@ -103,18 +160,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
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.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **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
|
## Client work rules
|
||||||
|
|
||||||
@@ -160,15 +263,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
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.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,7 +288,7 @@ Key skills:
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -211,8 +316,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -250,4 +358,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
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,7 +9,7 @@
|
|||||||
- **Name**: gitea-mcp
|
- **Name**: gitea-mcp
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal
|
||||||
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
|
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
|
||||||
- **Status**: active
|
- **Status**: active
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -37,8 +37,11 @@
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- 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
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -76,4 +79,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
6. Commit directly to main. Run `task check` before every push. Never create
|
||||||
|
feature branches unless a parallel agent is simultaneously active on this repo.
|
||||||
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
|
|
||||||
|
### What shipped
|
||||||
|
|
||||||
|
| Tag | PR | Tools / fixes |
|
||||||
|
|-----|----|---------------|
|
||||||
|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
|
||||||
|
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
|
||||||
|
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
|
||||||
|
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
|
||||||
|
|
||||||
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
|
|
||||||
|
### Next up
|
||||||
|
|
||||||
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
|
|
||||||
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
|
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
|
||||||
|
|||||||
+15
-1
@@ -1,9 +1,23 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
|
||||||
|
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||||
|
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||||
|
# the module proxy + sumdb.
|
||||||
|
RUN apk add --no-cache git && \
|
||||||
|
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
|
||||||
|
ENV GOPRIVATE=git.d-ma.be
|
||||||
|
ENV GOPROXY=direct
|
||||||
|
ENV GOSUMDB=off
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
|
# Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
|
||||||
|
# "dev" for a plain `docker build` (#47).
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
COPY --from=build /out/gitea-mcp /gitea-mcp
|
COPY --from=build /out/gitea-mcp /gitea-mcp
|
||||||
|
|||||||
@@ -2,3 +2,14 @@
|
|||||||
|
|
||||||
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
||||||
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task setup:hooks # installs .githooks/pre-push — runs task check before every push
|
||||||
|
task check # context sync + lint + test + vet
|
||||||
|
task build # produces bin/gitea-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
|
||||||
|
hook enforces the quality gate locally; CI re-runs `task check` on every push.
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- bash scripts/context-sync.sh
|
- bash scripts/context-sync.sh
|
||||||
|
|
||||||
|
setup:hooks:
|
||||||
|
desc: Install git hooks (.githooks/pre-push)
|
||||||
|
cmds:
|
||||||
|
- git config core.hooksPath .githooks
|
||||||
|
- chmod +x .githooks/pre-push
|
||||||
|
- echo "✓ git hooks installed (pre-push runs task check)"
|
||||||
|
|
||||||
context:sync:claude:
|
context:sync:claude:
|
||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
context:sync:agents:
|
context:sync:agents:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type healthStatus struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
JWT jwtStatus `json:"jwt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtStatus surfaces the runtime state of the Dex JWT validator so ops
|
||||||
|
// can distinguish "Dex unreachable at startup" from "JWT auth not
|
||||||
|
// configured" — both previously degraded silently to static-token-only
|
||||||
|
// (refs hyperguild/gitea-mcp#6).
|
||||||
|
type jwtStatus struct {
|
||||||
|
// Status is one of: "disabled" (DEX_ISSUER_URL not set),
|
||||||
|
// "enabled" (validator initialized), "degraded" (configured but
|
||||||
|
// init failed; only static-token auth currently accepted).
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
||||||
|
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
||||||
|
if initErr != nil {
|
||||||
|
status.JWT.LastError = initErr.Error()
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(status)
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtStatusFor(dexConfigured, validatorReady bool) string {
|
||||||
|
switch {
|
||||||
|
case !dexConfigured:
|
||||||
|
return "disabled"
|
||||||
|
case validatorReady:
|
||||||
|
return "enabled"
|
||||||
|
default:
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthzHandler(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dexConfigured bool
|
||||||
|
validatorReady bool
|
||||||
|
initErr error
|
||||||
|
wantStatus string
|
||||||
|
wantLastError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled when DEX_ISSUER_URL unset",
|
||||||
|
wantStatus: "disabled",
|
||||||
|
wantLastError: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled when validator initialized",
|
||||||
|
dexConfigured: true,
|
||||||
|
validatorReady: true,
|
||||||
|
wantStatus: "enabled",
|
||||||
|
wantLastError: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "degraded when Dex configured but init failed",
|
||||||
|
dexConfigured: true,
|
||||||
|
initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"),
|
||||||
|
wantStatus: "degraded",
|
||||||
|
wantLastError: "fetch oidc discovery: dial tcp: connection refused",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var got healthStatus
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.True(t, got.OK)
|
||||||
|
assert.Equal(t, tc.wantStatus, got.JWT.Status)
|
||||||
|
assert.Equal(t, tc.wantLastError, got.JWT.LastError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-55
@@ -2,22 +2,32 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
|
||||||
|
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
|
||||||
|
// "0.1.0" — it now tells the truth instead of a stale literal).
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
|
||||||
|
// the same structured handler as the rest of the server.
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,73 +37,44 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||||
if err != nil {
|
if jwtInitErr != nil {
|
||||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
|
||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
|
||||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
|
||||||
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
Sessions: mcp.NewSessionStore(),
|
Sessions: mcp.NewSessionStore(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
||||||
|
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
||||||
|
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
||||||
|
var resourceMetadataURL string
|
||||||
|
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
||||||
|
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
auth.CallerMiddleware(mcpSrv),
|
auth.CallerMiddleware(logger, mcpSrv),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
payload := map[string]any{
|
|
||||||
"resource": cfg.MCPResourceURL,
|
|
||||||
"authorization_servers": []string{},
|
|
||||||
}
|
|
||||||
if cfg.DexIssuerURL != "" {
|
if cfg.DexIssuerURL != "" {
|
||||||
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
logger.Error("server stopped", "err", err)
|
logger.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
module gitea.d-ma.be/mathias/gitea-mcp
|
module git.d-ma.be/mathias/gitea-mcp
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.3.0
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package allowlist_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/subtle"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BearerMiddleware authenticates requests via the Authorization header.
|
|
||||||
//
|
|
||||||
// A request is allowed when:
|
|
||||||
//
|
|
||||||
// 1. The Bearer token is a valid JWT issued by the configured Dex OIDC server, or
|
|
||||||
// 2. The Bearer token matches staticToken (constant-time compare).
|
|
||||||
//
|
|
||||||
// Any other case — including missing or empty Authorization header — returns 401.
|
|
||||||
//
|
|
||||||
// The Gitea service PAT is intentionally NOT used to authenticate the caller:
|
|
||||||
// it is only used by the Gitea client for upstream API calls. Decoupling the
|
|
||||||
// two prevents the MCP endpoint from being reachable anonymously when a service
|
|
||||||
// PAT happens to be configured.
|
|
||||||
func BearerMiddleware(jwtValidator *JWTValidator, staticToken string, next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
if !hasBearer || bearer == "" {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if jwtValidator.Validate(r.Context(), bearer) {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package auth_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func okHandler(called *bool) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
if called != nil {
|
|
||||||
*called = true
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_NoAuthHeader(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_NoAuthHeader_RejectsEvenWhenStaticConfigured(t *testing.T) {
|
|
||||||
// A configured staticToken must not allow unauthenticated callers through.
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "any-static", okHandler(nil)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_EmptyBearer(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "static", okHandler(nil)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer ")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_StaticToken_Valid(t *testing.T) {
|
|
||||||
const staticToken = "my-static-token"
|
|
||||||
called := false
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, okHandler(&called)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+staticToken)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
assert.True(t, called)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", okHandler(nil)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBearerMiddleware_UnknownBearer_NoStatic_NoJWT(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer random-unknown-token")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
+26
-3
@@ -2,17 +2,40 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey struct{}
|
type ctxKey struct{}
|
||||||
|
|
||||||
func CallerMiddleware(next http.Handler) http.Handler {
|
// CallerMiddleware extracts the authenticated username from the reverse-proxy
|
||||||
|
// identity headers and stashes it in the request context for Caller().
|
||||||
|
//
|
||||||
|
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
|
||||||
|
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
|
||||||
|
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
|
||||||
|
// convention some setups populate instead; it is used only as a fallback when
|
||||||
|
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
|
||||||
|
// verified X-Auth-Request-User still wins and we log a warning so the
|
||||||
|
// misconfiguration is visible rather than silently resolved (#10).
|
||||||
|
//
|
||||||
|
// logger may be nil, in which case the conflict warning is skipped.
|
||||||
|
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user := r.Header.Get("X-Auth-Request-User")
|
authUser := r.Header.Get("X-Auth-Request-User")
|
||||||
|
fwdUser := r.Header.Get("X-Forwarded-User")
|
||||||
|
|
||||||
|
user := authUser
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = r.Header.Get("X-Forwarded-User")
|
user = fwdUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
|
||||||
|
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
|
||||||
|
"x_auth_request_user", authUser,
|
||||||
|
"x_forwarded_user", fwdUser)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,80 @@
|
|||||||
package auth_test
|
package auth_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCallerFromContext(t *testing.T) {
|
func discardLogger() *slog.Logger {
|
||||||
called := false
|
return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
|
||||||
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
}
|
||||||
called = true
|
|
||||||
assert.Equal(t, "mathiasbq", auth.Caller(r.Context()))
|
// Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
|
||||||
|
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
|
||||||
|
// absent.
|
||||||
|
func TestCallerHeaderPrecedence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authReq string
|
||||||
|
forwarded string
|
||||||
|
wantCaller string
|
||||||
|
}{
|
||||||
|
{"auth-request only", "mathiasbq", "", "mathiasbq"},
|
||||||
|
{"forwarded fallback", "", "fwduser", "fwduser"},
|
||||||
|
{"both present, same", "same", "same", "same"},
|
||||||
|
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
|
||||||
|
{"neither", "", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
got = auth.Caller(r.Context())
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
req.Header.Set("X-Auth-Request-User", "mathiasbq")
|
if tc.authReq != "" {
|
||||||
rr := httptest.NewRecorder()
|
req.Header.Set("X-Auth-Request-User", tc.authReq)
|
||||||
h.ServeHTTP(rr, req)
|
}
|
||||||
assert.True(t, called)
|
if tc.forwarded != "" {
|
||||||
|
req.Header.Set("X-Forwarded-User", tc.forwarded)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
assert.Equal(t, tc.wantCaller, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When both headers are present and disagree, a warning is logged so the proxy
|
||||||
|
// misconfiguration is visible rather than silent.
|
||||||
|
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
|
||||||
|
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
req.Header.Set("X-Auth-Request-User", "authuser")
|
||||||
|
req.Header.Set("X-Forwarded-User", "fwduser")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
assert.Contains(t, logged, "conflicting")
|
||||||
|
assert.Contains(t, logged, "authuser")
|
||||||
|
assert.Contains(t, logged, "fwduser")
|
||||||
|
|
||||||
|
// No warning when they agree.
|
||||||
|
buf.Reset()
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
req2.Header.Set("X-Auth-Request-User", "same")
|
||||||
|
req2.Header.Set("X-Forwarded-User", "same")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req2)
|
||||||
|
assert.Empty(t, buf.String(), "no warning expected when headers agree")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
|
func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
|
|
||||||
// A nil JWTValidator always returns false — JWT validation is disabled.
|
|
||||||
type JWTValidator struct {
|
|
||||||
issuer string
|
|
||||||
aud string
|
|
||||||
cache *jwk.Cache
|
|
||||||
jwksURI string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewJWTValidator creates a validator by fetching the OIDC discovery document
|
|
||||||
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
|
|
||||||
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
|
||||||
if issuerURL == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
var doc struct {
|
|
||||||
JWKSURI string `json:"jwks_uri"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := jwk.NewCache(ctx)
|
|
||||||
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
|
|
||||||
return nil, fmt.Errorf("register jwks uri: %w", err)
|
|
||||||
}
|
|
||||||
// warm the cache immediately so first request doesn't block
|
|
||||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
|
||||||
return nil, fmt.Errorf("warm jwks cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &JWTValidator{
|
|
||||||
issuer: issuerURL,
|
|
||||||
aud: audience,
|
|
||||||
cache: cache,
|
|
||||||
jwksURI: doc.JWKSURI,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
|
|
||||||
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
|
|
||||||
if v == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
opts := []jwt.ParseOption{
|
|
||||||
jwt.WithKeySet(keySet),
|
|
||||||
jwt.WithIssuer(v.issuer),
|
|
||||||
jwt.WithValidate(true),
|
|
||||||
}
|
|
||||||
if v.aud != "" {
|
|
||||||
opts = append(opts, jwt.WithAudience(v.aud))
|
|
||||||
}
|
|
||||||
_, err = jwt.Parse([]byte(rawToken), opts...)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
"git.d-ma.be/mathias/gitea-mcp/internal/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||||
@@ -40,7 +42,21 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
|
|||||||
return repo.DefaultBranch, nil
|
return repo.DefaultBranch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasEmptySegment reports whether the path portion (before any query string)
|
||||||
|
// contains an empty segment ("//"), which means an owner or repo path
|
||||||
|
// parameter was empty. Forwarding it upstream yields gitea's opaque
|
||||||
|
// /api/swagger 404 (#36), so callers reject it locally instead.
|
||||||
|
func hasEmptySegment(path string) bool {
|
||||||
|
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||||
|
path = path[:i]
|
||||||
|
}
|
||||||
|
return strings.Contains(path, "//")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||||
|
if hasEmptySegment(path) {
|
||||||
|
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||||
|
}
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
reader = bytes.NewReader(body)
|
reader = bytes.NewReader(body)
|
||||||
@@ -107,6 +123,9 @@ type rawResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
||||||
|
if hasEmptySegment(path) {
|
||||||
|
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||||
|
}
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
reader = bytes.NewReader(body)
|
reader = bytes.NewReader(body)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #36 second line of defence: when owner or repo is empty the path contains an
|
||||||
|
// empty segment ("//"). Rather than forward it upstream — where gitea answers
|
||||||
|
// with its opaque /api/swagger 404 — the client must reject it locally with a
|
||||||
|
// validation error and never touch the network.
|
||||||
|
func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
"/api/v1/repos/mathias//issues", // empty repo
|
||||||
|
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||||
|
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
_, _, err := c.GetJSON(context.Background(), p)
|
||||||
|
require.Error(t, err, "path %q should be rejected", p)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "guard must short-circuit before any HTTP call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well-formed path with a query string must still pass the guard.
|
||||||
|
func TestWellFormedPathPasses(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, status, err := c.GetJSON(context.Background(), "/api/v1/repos/mathias/gitea-mcp/issues?state=open")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, status)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
|
|||||||
if err := MapStatus(status, body); err != nil {
|
if err := MapStatus(status, body); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Array response means path is a directory — guide caller to dir_list.
|
||||||
|
if len(body) > 0 && body[0] == '[' {
|
||||||
|
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
|
||||||
|
}
|
||||||
var fc FileContents
|
var fc FileContents
|
||||||
if err := json.Unmarshal(body, &fc); err != nil {
|
if err := json.Unmarshal(body, &fc); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
@@ -12,6 +14,21 @@ type Issue struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
Assignees []User `json:"assignees"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateIssueArgs struct {
|
type CreateIssueArgs struct {
|
||||||
@@ -22,6 +39,22 @@ type CreateIssueArgs struct {
|
|||||||
Milestone int64 `json:"milestone,omitempty"`
|
Milestone int64 `json:"milestone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
@@ -42,10 +75,128 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
|
|||||||
return &iss, nil
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListIssuesArgs captures the optional query params for ListIssues.
|
||||||
|
type ListIssuesArgs struct {
|
||||||
|
State string // "open" | "closed" | "all"
|
||||||
|
Labels string // comma-separated label names
|
||||||
|
Since string // ISO 8601
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssues fetches issues for a repo. Pulls are excluded server-side
|
||||||
|
// (type=issues) so they don't leak through the same endpoint.
|
||||||
|
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("type", "issues")
|
||||||
|
if args.State != "" {
|
||||||
|
q.Set("state", args.State)
|
||||||
|
}
|
||||||
|
if args.Labels != "" {
|
||||||
|
q.Set("labels", args.Labels)
|
||||||
|
}
|
||||||
|
if args.Since != "" {
|
||||||
|
q.Set("since", args.Since)
|
||||||
|
}
|
||||||
|
if args.Page > 0 {
|
||||||
|
q.Set("page", strconv.Itoa(args.Page))
|
||||||
|
}
|
||||||
|
if args.Limit > 0 {
|
||||||
|
q.Set("limit", strconv.Itoa(args.Limit))
|
||||||
|
}
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var issues []Issue
|
||||||
|
if err := json.Unmarshal(body, &issues); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIssueState flips an issue between "open" and "closed" via PATCH.
|
||||||
|
// Gitea uses the same endpoint for both transitions.
|
||||||
|
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(map[string]string{"state": state})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil,
|
||||||
|
// left untouched) from an explicit empty string (clears the field). Maps to
|
||||||
|
// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}.
|
||||||
|
type EditIssueArgs struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssue patches an issue's title and/or body. Only fields set in args are
|
||||||
|
// sent, so omitted fields are left as-is server-side. Body is sent verbatim —
|
||||||
|
// no identity footer — so repeated edits are idempotent.
|
||||||
|
func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
type IssueComment struct {
|
type IssueComment struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
User User `json:"user,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueComments fetches all comments on an issue or pull request.
|
||||||
|
// Per Gitea, /issues/{index}/comments serves both since PRs share index space with issues.
|
||||||
|
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int) ([]IssueComment, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var comments []IssueComment
|
||||||
|
if err := json.Unmarshal(body, &comments); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -45,6 +45,127 @@ func TestCreateIssue(t *testing.T) {
|
|||||||
assert.Equal(t, "open", iss.State)
|
assert.Equal(t, "open", iss.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIssue(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "fix auth", iss.Title)
|
||||||
|
assert.Equal(t, "open", iss.State)
|
||||||
|
assert.Equal(t, 3, iss.Comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditIssue(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title, body := "new title", "new body"
|
||||||
|
iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "new title", payload["title"])
|
||||||
|
assert.Equal(t, "new body", payload["body"])
|
||||||
|
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "new title", iss.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssue must send only the fields explicitly provided — an omitted field
|
||||||
|
// (nil pointer) is left untouched server-side.
|
||||||
|
func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title := "only title"
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "only title", payload["title"])
|
||||||
|
_, hasBody := payload["body"]
|
||||||
|
assert.False(t, hasBody, "body must be omitted when not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted.
|
||||||
|
func TestEditIssue_EmptyBodyIsSent(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
body := ""
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
val, hasBody := payload["body"]
|
||||||
|
assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field")
|
||||||
|
assert.Equal(t, "", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title := "x"
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateIssueComment(t *testing.T) {
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
var captured []byte
|
var captured []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -70,3 +191,51 @@ func TestCreateIssueComment(t *testing.T) {
|
|||||||
assert.Equal(t, "hello", comment.Body)
|
assert.Equal(t, "hello", comment.Body)
|
||||||
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||||
|
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, comments, 2)
|
||||||
|
assert.Equal(t, int64(1), comments[0].ID)
|
||||||
|
assert.Equal(t, "first", comments[0].Body)
|
||||||
|
assert.Equal(t, "alice", comments[0].User.Login)
|
||||||
|
assert.Equal(t, "bob", comments[1].User.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListLabels fetches all labels defined on a repo.
|
||||||
|
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var labels []Label
|
||||||
|
if err := json.Unmarshal(body, &labels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
|
||||||
|
// space with issues, per Gitea). This is additive per Gitea's own POST
|
||||||
|
// semantics — existing labels are left in place, no replace/delete needed.
|
||||||
|
// Returns the issue's full label set after the add.
|
||||||
|
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var labels []Label
|
||||||
|
if err := json.Unmarshal(body, &labels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListLabels(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, labels, 2)
|
||||||
|
assert.Equal(t, int64(1), labels[0].ID)
|
||||||
|
assert.Equal(t, "bug", labels[0].Name)
|
||||||
|
assert.Equal(t, "ee0701", labels[0].Color)
|
||||||
|
assert.Equal(t, "enhancement", labels[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLabels_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLabels_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.ListLabels(context.Background(), "o", "r")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddIssueLabels(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":3,"name":"priority","color":"00ff00"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
ids, ok := payload["labels"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, ids, 1)
|
||||||
|
assert.Equal(t, float64(3), ids[0])
|
||||||
|
|
||||||
|
require.Len(t, labels, 2)
|
||||||
|
assert.Equal(t, "bug", labels[0].Name)
|
||||||
|
assert.Equal(t, "priority", labels[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddIssueLabels_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PushMirror struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
RemoteName string `json:"remote_name"`
|
||||||
|
RemoteAddress string `json:"remote_address"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
SyncOnCommit bool `json:"sync_on_commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddPushMirrorArgs struct {
|
||||||
|
RemoteAddress string `json:"remote_address"`
|
||||||
|
RemoteUsername string `json:"remote_username,omitempty"`
|
||||||
|
RemotePassword string `json:"remote_password,omitempty"`
|
||||||
|
Interval string `json:"interval,omitempty"`
|
||||||
|
SyncOnCommit bool `json:"sync_on_commit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m PushMirror
|
||||||
|
if err := json.Unmarshal(resp, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||||
|
resp, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var mirrors []PushMirror
|
||||||
|
if err := json.Unmarshal(resp, &mirrors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mirrors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName)
|
||||||
|
resp, status, err := c.DeleteJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddPushMirror(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
|
||||||
|
RemoteAddress: "https://github.com/mathias/infra.git",
|
||||||
|
RemoteUsername: "mathias",
|
||||||
|
RemotePassword: "secret",
|
||||||
|
Interval: "8h0m0s",
|
||||||
|
SyncOnCommit: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "mirror-github", m.RemoteName)
|
||||||
|
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPushMirrors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, mirrors, 1)
|
||||||
|
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePushMirror(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -16,10 +16,12 @@ type PullRequest struct {
|
|||||||
Draft bool `json:"draft"`
|
Draft bool `json:"draft"`
|
||||||
Head struct {
|
Head struct {
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
} `json:"head"`
|
} `json:"head"`
|
||||||
Base struct {
|
Base struct {
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
} `json:"base"`
|
} `json:"base"`
|
||||||
|
Mergeable bool `json:"mergeable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePullRequestArgs struct {
|
type CreatePullRequestArgs struct {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,109 @@ type Repo struct {
|
|||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "blob" or "tree"
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tree struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tree []TreeEntry `json:"tree"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref))
|
||||||
|
if recursive {
|
||||||
|
path += "?recursive=1"
|
||||||
|
}
|
||||||
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var t Tree
|
||||||
|
if err := json.Unmarshal(body, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReleaseArgs struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Draft bool `json:"draft,omitempty"`
|
||||||
|
Prerelease bool `json:"prerelease,omitempty"`
|
||||||
|
// Target branch or commit SHA for tag creation. Empty = repo default branch.
|
||||||
|
Target string `json:"target_commitish,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRelease(ctx context.Context, owner, repo string, args CreateReleaseArgs) (*Release, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner, repo)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Release
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo)
|
||||||
|
resp, status, err := c.DeleteJSON(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo)
|
||||||
|
body, err := json.Marshal(map[string][]string{"topics": topics})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PutJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MapStatus(status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -71,6 +174,72 @@ func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit i
|
|||||||
return env.Data, nil
|
return env.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateRepoArgs struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Private bool `json:"private,omitempty"`
|
||||||
|
AutoInit bool `json:"auto_init,omitempty"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
// Org, when non-empty, creates the repo under the named organisation.
|
||||||
|
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
||||||
|
Org string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
||||||
|
var path string
|
||||||
|
if args.Org != "" {
|
||||||
|
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
||||||
|
} else {
|
||||||
|
path = "/api/v1/user/repos"
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PostJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
||||||
|
type UpdateRepoArgs struct {
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||||
|
Archived *bool `json:"archived,omitempty"`
|
||||||
|
Template *bool `json:"template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
body, status, err := c.GetJSON(ctx, path)
|
||||||
@@ -86,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -47,6 +47,129 @@ func TestListRepos(t *testing.T) {
|
|||||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_User(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
|
Name: "infra",
|
||||||
|
Private: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "mathias/infra", r.FullName)
|
||||||
|
assert.Equal(t, "main", r.DefaultBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRepo_Org(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
|
Name: "infra",
|
||||||
|
Org: "hyperguild",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
desc := "updated"
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
|
||||||
|
Description: &desc,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "updated", r.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTree(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||||
|
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "abc", tree.SHA)
|
||||||
|
require.Len(t, tree.Tree, 2)
|
||||||
|
assert.Equal(t, "README.md", tree.Tree[0].Path)
|
||||||
|
assert.Equal(t, "blob", tree.Tree[0].Type)
|
||||||
|
assert.Equal(t, int64(13), tree.Tree[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTopics(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRelease(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||||
|
TagName: "v1.0.0",
|
||||||
|
Name: "v1.0.0",
|
||||||
|
Body: "first release",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||||
|
assert.Equal(t, "first release", rel.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||||
var hits int32
|
var hits int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
+76
-26
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
|
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
|
||||||
@@ -14,51 +14,45 @@ type DispatchWorkflowArgs struct {
|
|||||||
Inputs map[string]any `json:"inputs,omitempty"`
|
Inputs map[string]any `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkflowRunTrigger holds the run ID extracted from the Location header.
|
// DispatchWorkflow triggers a workflow_dispatch event. Gitea returns 204 No
|
||||||
type WorkflowRunTrigger struct {
|
// Content with NO Location header, so the response carries no run ID — callers
|
||||||
RunID int64
|
// resolve the new run separately via ListWorkflowRuns. Returns nil on success.
|
||||||
}
|
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) error {
|
||||||
|
|
||||||
// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID.
|
|
||||||
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) {
|
|
||||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
|
||||||
payload, err := json.Marshal(args)
|
payload, err := json.Marshal(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
resp, err := c.doRaw(ctx, "POST", p, payload)
|
resp, err := c.doRaw(ctx, "POST", p, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if resp.Status != 204 {
|
if resp.Status != 204 {
|
||||||
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
|
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
|
||||||
return nil, mapErr
|
return mapErr
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unexpected status %d", resp.Status)
|
return fmt.Errorf("unexpected status %d", resp.Status)
|
||||||
}
|
}
|
||||||
location := resp.Headers.Get("Location")
|
return nil
|
||||||
if location == "" {
|
|
||||||
return nil, fmt.Errorf("missing Location header in dispatch response")
|
|
||||||
}
|
|
||||||
// Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment.
|
|
||||||
parts := strings.Split(strings.TrimRight(location, "/"), "/")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return nil, fmt.Errorf("malformed Location: %s", location)
|
|
||||||
}
|
|
||||||
runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse run id from %q: %w", location, err)
|
|
||||||
}
|
|
||||||
return &WorkflowRunTrigger{RunID: runID}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkflowRun represents a Gitea Actions run.
|
// WorkflowRun represents a Gitea Actions run.
|
||||||
type WorkflowRun struct {
|
type WorkflowRun struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
DisplayTitle string `json:"display_title,omitempty"`
|
||||||
Status string `json:"status"` // queued | in_progress | completed
|
Status string `json:"status"` // queued | in_progress | completed
|
||||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
HeadSHA string `json:"head_sha,omitempty"`
|
||||||
|
HeadBranch string `json:"head_branch,omitempty"`
|
||||||
|
WorkflowID string `json:"workflow_id,omitempty"`
|
||||||
|
RunNumber int64 `json:"run_number,omitempty"`
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Actor struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"actor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkflowRun fetches the status of a specific Actions run.
|
// GetWorkflowRun fetches the status of a specific Actions run.
|
||||||
@@ -77,3 +71,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
|
|||||||
}
|
}
|
||||||
return &run, nil
|
return &run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
|
||||||
|
type ListWorkflowRunsArgs struct {
|
||||||
|
Branch string
|
||||||
|
HeadSHA string
|
||||||
|
Status string // queued | in_progress | completed | all
|
||||||
|
Event string // push | pull_request | schedule | workflow_dispatch | all
|
||||||
|
Workflow string
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowRunsResponse struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
|
||||||
|
// Status / Event of "all" or "" are treated as no-filter.
|
||||||
|
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
if args.Branch != "" {
|
||||||
|
q.Set("branch", args.Branch)
|
||||||
|
}
|
||||||
|
if args.HeadSHA != "" {
|
||||||
|
q.Set("head_sha", args.HeadSHA)
|
||||||
|
}
|
||||||
|
if args.Status != "" && args.Status != "all" {
|
||||||
|
q.Set("status", args.Status)
|
||||||
|
}
|
||||||
|
if args.Event != "" && args.Event != "all" {
|
||||||
|
q.Set("event", args.Event)
|
||||||
|
}
|
||||||
|
if args.Workflow != "" {
|
||||||
|
q.Set("workflow", args.Workflow)
|
||||||
|
}
|
||||||
|
if args.Page > 0 {
|
||||||
|
q.Set("page", strconv.Itoa(args.Page))
|
||||||
|
}
|
||||||
|
if args.Limit > 0 {
|
||||||
|
q.Set("limit", strconv.Itoa(args.Limit))
|
||||||
|
}
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp workflowRunsResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Gitea's dispatch endpoint returns 204 No Content with NO Location header.
|
||||||
|
// Dispatch must succeed and forward ref+inputs in the body; the run ID is
|
||||||
|
// resolved separately by the tool via ListWorkflowRuns.
|
||||||
func TestDispatchWorkflow(t *testing.T) {
|
func TestDispatchWorkflow(t *testing.T) {
|
||||||
var gotBody []byte
|
var gotBody []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -22,18 +25,17 @@ func TestDispatchWorkflow(t *testing.T) {
|
|||||||
var err error
|
var err error
|
||||||
gotBody, err = io.ReadAll(r.Body)
|
gotBody, err = io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789")
|
// No Location header — matches real Gitea.
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
|
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
|
||||||
Ref: "main",
|
Ref: "main",
|
||||||
Inputs: map[string]any{"env": "prod"},
|
Inputs: map[string]any{"env": "prod"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, int64(789), result.RunID)
|
|
||||||
|
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
require.NoError(t, json.Unmarshal(gotBody, &body))
|
require.NoError(t, json.Unmarshal(gotBody, &body))
|
||||||
@@ -43,19 +45,6 @@ func TestDispatchWorkflow(t *testing.T) {
|
|||||||
assert.Equal(t, "prod", inputs["env"])
|
assert.Equal(t, "prod", inputs["env"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDispatchWorkflowMissingLocation(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// 204 but no Location header
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
|
||||||
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "Location")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatchWorkflowError404(t *testing.T) {
|
func TestDispatchWorkflowError404(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -63,7 +52,7 @@ func TestDispatchWorkflowError404(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.True(t, errors.Is(err, gitea.ErrNotFound))
|
assert.True(t, errors.Is(err, gitea.ErrNotFound))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package identity_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #38: `repo` (and `number`) are the canonical, idiomatic gitea/GitHub arg names
|
||||||
|
// that identifier tools advertise. `name` is kept as a back-compat alias via the
|
||||||
|
// bidirectional shim, so old `name` callers still work. `index`->`number` stays.
|
||||||
|
// An explicit canonical (`repo`) always wins over its alias (`name`).
|
||||||
|
func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{"canonical repo+number", `{"owner":"mathias","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"repo+index alias", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"legacy name alias", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"explicit repo wins over name", `{"owner":"mathias","name":"ignored","repo":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":7,"title":"t"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(tc.args))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantPath, gotPath)
|
||||||
|
assert.Contains(t, string(out), `"number":7`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #38: identifier tools must ADVERTISE `repo` (not `name`) in their schema, so
|
||||||
|
// the contract a client reads matches what every caller sends. The two create
|
||||||
|
// tools keep `name` because there it means "name of the new repo", not an
|
||||||
|
// existing-repo identifier.
|
||||||
|
func TestRepoIsCanonicalInAdvertisedSchema(t *testing.T) {
|
||||||
|
c := gitea.NewClient("http://unused", "")
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
|
||||||
|
identifier := map[string]json.RawMessage{
|
||||||
|
"repo_get": tools.NewRepoGet(c, a).Descriptor().InputSchema,
|
||||||
|
"issue_get": tools.NewIssueGet(c, a).Descriptor().InputSchema,
|
||||||
|
"pr_merge": tools.NewPRMerge(c, a).Descriptor().InputSchema,
|
||||||
|
"repo_delete": tools.NewRepoDelete(c, a).Descriptor().InputSchema,
|
||||||
|
"file_read": tools.NewFileRead(c, a).Descriptor().InputSchema,
|
||||||
|
}
|
||||||
|
for name, sch := range identifier {
|
||||||
|
s := string(sch)
|
||||||
|
assert.Contains(t, s, `"repo":`, name+" must advertise repo")
|
||||||
|
assert.NotContains(t, s, `"name":`, name+" must not advertise name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create tools keep `name` (new resource name, not an existing-repo id)
|
||||||
|
assert.Contains(t, string(tools.NewRepoCreate(c, a).Descriptor().InputSchema), `"name":`)
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchDelete struct {
|
type BranchDelete struct {
|
||||||
@@ -27,17 +27,17 @@ func (t *BranchDelete) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"branch":{"type":"string"}
|
"branch":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","branch"]
|
"required":["owner","repo","branch"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchDeleteArgs struct {
|
type branchDeleteArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
|||||||
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.c.DeleteBranch(ctx, args.Owner, args.Name, args.Branch); err != nil {
|
if err := t.c.DeleteBranch(ctx, args.Owner, args.Repo, args.Branch); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchList struct {
|
type BranchList struct {
|
||||||
@@ -26,18 +26,18 @@ func (t *BranchList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"page":{"type":"integer","minimum":1},
|
"page":{"type":"integer","minimum":1},
|
||||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchListArgs struct {
|
type branchListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
branches, err := t.c.ListBranches(ctx, args.Owner, args.Name, args.Page, capLimit(args.Limit, 30))
|
branches, err := t.c.ListBranches(ctx, args.Owner, args.Repo, args.Page, capLimit(args.Limit, 30))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BranchProtectionGet struct {
|
type BranchProtectionGet struct {
|
||||||
@@ -26,17 +26,17 @@ func (t *BranchProtectionGet) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"branch":{"type":"string"}
|
"branch":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","branch"]
|
"required":["owner","repo","branch"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type branchProtectionGetArgs struct {
|
type branchProtectionGetArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Name, args.Branch)
|
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, args.Branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type semaphore chan struct{}
|
type semaphore chan struct{}
|
||||||
@@ -49,7 +49,7 @@ func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
|
|||||||
type codeSearchArgs struct {
|
type codeSearchArgs struct {
|
||||||
Q string `json:"q"`
|
Q string `json:"q"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo,omitempty"` // optional: empty => owner-wide fan-out (#37 opt-out)
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,34 +2,37 @@ package tools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||||
|
|
||||||
var substitutionFiles = []string{
|
|
||||||
"go.mod",
|
|
||||||
"Taskfile.yml",
|
|
||||||
"Dockerfile",
|
|
||||||
".gitea/workflows/cd.yml",
|
|
||||||
"README.md",
|
|
||||||
".context/PROJECT.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
func substitutions(owner, name string) map[string]string {
|
func substitutions(owner, name string) map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"__PROJECT_NAME__": name,
|
"__PROJECT_NAME__": name,
|
||||||
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
// git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be
|
||||||
|
// rename; a stale host breaks `go mod download` for downstream consumers).
|
||||||
|
"__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyReplacements(s string, repls map[string]string) string {
|
||||||
|
for k, v := range repls {
|
||||||
|
s = strings.ReplaceAll(s, k, v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||||
type CreateProjectFromTemplate struct {
|
type CreateProjectFromTemplate struct {
|
||||||
c *gitea.Client
|
c *gitea.Client
|
||||||
@@ -45,14 +48,16 @@ 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 the template, applying placeholder substitutions to known files.",
|
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains 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).",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"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."},
|
||||||
|
"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."}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -64,8 +69,16 @@ type createProjectArgs struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
|
TemplateName string `json:"template_name"`
|
||||||
|
DispatchAllow bool `json:"dispatch_allow"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
||||||
|
// sandbox convention: presence of the file (not its content) marks the repo
|
||||||
|
// dispatch-eligible; the comment exists only to explain that to a human reader.
|
||||||
|
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
|
||||||
|
"# See dispatch#3.\n"
|
||||||
|
|
||||||
type createProjectResult struct {
|
type createProjectResult struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
@@ -91,13 +104,20 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve template: per-call override takes precedence over the
|
||||||
|
// server-configured default. Owner stays server-configured.
|
||||||
|
tmplName := args.TemplateName
|
||||||
|
if tmplName == "" {
|
||||||
|
tmplName = t.templateName
|
||||||
|
}
|
||||||
|
|
||||||
// Verify template exists and is marked as a template repo.
|
// Verify template exists and is marked as a template repo.
|
||||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("template lookup: %w", err)
|
return nil, 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, t.templateName, gitea.ErrValidation)
|
return nil, 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.
|
||||||
@@ -108,7 +128,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate repo from template.
|
// Generate repo from template.
|
||||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
||||||
Owner: args.Owner,
|
Owner: args.Owner,
|
||||||
Name: args.Name,
|
Name: args.Name,
|
||||||
Description: args.Description,
|
Description: args.Description,
|
||||||
@@ -126,21 +146,178 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
DefaultBranch: newRepo.DefaultBranch,
|
DefaultBranch: newRepo.DefaultBranch,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Substitute placeholders in known files (best-effort).
|
// The /generate response often omits default_branch — resolve it explicitly,
|
||||||
repls := substitutions(args.Owner, args.Name)
|
// otherwise every file read below hits an empty ref and nothing substitutes
|
||||||
|
// (the silent-null bug: gitea-mcp#42).
|
||||||
branch := newRepo.DefaultBranch
|
branch := newRepo.DefaultBranch
|
||||||
for _, path := range substitutionFiles {
|
if branch == "" {
|
||||||
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
|
||||||
// Files that don't exist in this template are silently skipped.
|
branch = r.DefaultBranch
|
||||||
if errors.Is(err, gitea.ErrNotFound) {
|
} else {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.DefaultBranch = branch
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
repls := substitutions(args.Owner, args.Name)
|
||||||
|
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
||||||
|
if err != nil {
|
||||||
|
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err)
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range tree.Tree {
|
||||||
|
if e.Type != "blob" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Any other error halts the substitution pass with partial_failure recorded.
|
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
||||||
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
if fail != "" {
|
||||||
|
result.PartialFailure = fail
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
if substituted != "" {
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt the new project into headless dispatch if asked: presence of a
|
||||||
|
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
||||||
|
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
|
||||||
|
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
|
||||||
|
// loop already stalled — a failed injection then degrades identically.
|
||||||
|
if args.DispatchAllow && result.PartialFailure == "" {
|
||||||
|
const dispatchAllowPath = ".dispatch-allow"
|
||||||
|
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
return textOK(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
||||||
|
// async template-generate (infra#179) leaves the branch unwritable within the
|
||||||
|
// budget. It names the concrete remaining work — substituting the two
|
||||||
|
// placeholders — rather than pointing at a specific tool, so the guidance stays
|
||||||
|
// correct regardless of scaffolding-CLI state (gitea-mcp#46).
|
||||||
|
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
||||||
|
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
||||||
|
"is incomplete (%d file(s) done). Finish it by cloning the repo and replacing the "+
|
||||||
|
"remaining __PROJECT_NAME__ / __MODULE_PATH__ placeholders (in file contents and "+
|
||||||
|
"paths), then pushing; or retry create once the branch settles. Underlying: %s",
|
||||||
|
branch, budget, done, underlying)
|
||||||
|
}
|
||||||
|
|
||||||
|
// substitutionBudget bounds how long we retry the first write while the freshly
|
||||||
|
// generated branch becomes writable. gitea's /generate returns (and serves reads)
|
||||||
|
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
||||||
|
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
||||||
|
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
||||||
|
// fails fast and we defer substitution with clear guidance rather than hang.
|
||||||
|
const substitutionBudget = 5
|
||||||
|
|
||||||
|
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
||||||
|
// exist" not-found, up to substitutionBudget. The write itself is the readiness
|
||||||
|
// probe — BranchExists reports the branch present before writes succeed.
|
||||||
|
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < substitutionBudget; i++ {
|
||||||
|
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
||||||
|
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
||||||
|
// content in place when changed. Returns a human-readable description of what was
|
||||||
|
// substituted ("" if nothing), and a non-empty partial-failure string on error.
|
||||||
|
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
|
||||||
|
newPath := applyReplacements(path, repls)
|
||||||
|
|
||||||
|
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return "", "" // vanished between tree walk and read; skip
|
||||||
|
}
|
||||||
|
return "", fmt.Sprintf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("decode %s: %v", path, err)
|
||||||
|
}
|
||||||
|
newContent := applyReplacements(string(decoded), repls)
|
||||||
|
renamed := newPath != path
|
||||||
|
changed := newContent != string(decoded)
|
||||||
|
if !renamed && !changed {
|
||||||
|
return "", "" // nothing to do
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
||||||
|
|
||||||
|
if renamed {
|
||||||
|
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||||
|
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{
|
||||||
|
Branch: branch,
|
||||||
|
Content: enc,
|
||||||
|
Message: "template: substitute placeholders",
|
||||||
|
Sha: fc.Sha,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Sprintf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return path, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,262 +5,308 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||||
var substitutionFileList = []string{
|
|
||||||
"go.mod",
|
|
||||||
"Taskfile.yml",
|
|
||||||
"Dockerfile",
|
|
||||||
".gitea/workflows/cd.yml",
|
|
||||||
"README.md",
|
|
||||||
".context/PROJECT.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
// contentWithPlaceholder is a template file body that contains the placeholder.
|
func templateRepoJSON(name string, isTemplate bool) string {
|
||||||
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||||
|
|
||||||
func encodedContent(s string) string {
|
|
||||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileContentsJSON returns a JSON FileContents object for the given path.
|
|
||||||
func fileContentsJSON(path string) string {
|
|
||||||
enc := encodedContent(contentWithPlaceholder)
|
|
||||||
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
|
||||||
path, strings.ReplaceAll(path, "/", "-"), enc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
|
||||||
func fileWriteResultJSON(path string) string {
|
|
||||||
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
|
||||||
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
|
||||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
|
||||||
name, name, name, name, isTemplate)
|
name, name, name, name, isTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
|
||||||
func newGeneratedRepoJSON(name string) string {
|
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
|
||||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
type fakeTemplateServer struct {
|
||||||
name, name, name, name)
|
mu sync.Mutex
|
||||||
|
files map[string]string // path -> raw (un-substituted) content
|
||||||
|
genBranch string // default_branch returned by /generate ("" to force fallback)
|
||||||
|
generated bool
|
||||||
|
puts []string
|
||||||
|
deletes []string
|
||||||
|
putBodies map[string]string // path -> decoded written content
|
||||||
|
repoGetsPost int // GET dest after generate (branch fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||||
c := gitea.NewClient(srvURL, "tok")
|
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
||||||
a := allowlist.New([]string{"mathias"})
|
|
||||||
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
|
||||||
func TestCreateProjectHappyPath(t *testing.T) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
p := r.URL.Path
|
||||||
// Template repo lookup
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
// Destination repo lookup — 404 means it doesn't exist yet
|
switch {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
||||||
|
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
|
||||||
|
if !f.generated {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.repoGetsPost++
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
|
||||||
|
|
||||||
// Generate
|
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
f.generated = true
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
_, _ = 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)
|
||||||
|
|
||||||
// File contents GET — handle all 6 substitution files
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
var entries []string
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
for path := range f.files {
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
|
||||||
|
}
|
||||||
|
// include a tree (directory) entry to exercise the blob filter
|
||||||
|
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
|
||||||
// File contents PUT — handle all 6 substitution files
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
body, ok := f.files[path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
|
||||||
|
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
|
||||||
|
|
||||||
|
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
|
||||||
|
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
var args struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &args)
|
||||||
|
dec, _ := base64.StdEncoding.DecodeString(args.Content)
|
||||||
|
f.puts = append(f.puts, path)
|
||||||
|
f.putBodies[path] = string(dec)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
} else {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
|
||||||
|
|
||||||
|
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
|
f.deletes = append(f.deletes, path)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
defer srv.Close()
|
}
|
||||||
|
|
||||||
tool := newCreateProjectTool(srv.URL)
|
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate {
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
return tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
|
||||||
|
t.Helper()
|
||||||
|
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
var out createOut
|
||||||
|
require.NoError(t, json.Unmarshal(res, &out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
var out struct {
|
type createOut struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Happy path: whole-tree substitution, content + path rename, correct module host.
|
||||||
|
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module __MODULE_PATH__\n\ngo 1.26\n",
|
||||||
|
"README.md": "# __PROJECT_NAME__\n",
|
||||||
|
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||||
|
"pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(result, &out))
|
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.Equal(t, "mathias/new-svc", out.FullName)
|
|
||||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
|
||||||
assert.Equal(t, "main", out.DefaultBranch)
|
assert.Equal(t, "main", out.DefaultBranch)
|
||||||
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
|
||||||
|
// content-substituted files present; untouched file absent
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "README.md")
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
|
||||||
|
// path rename recorded as "old -> new"
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||||
|
|
||||||
|
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
|
||||||
|
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
|
||||||
|
// rename: new path written, old path deleted
|
||||||
|
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
|
||||||
|
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
|
||||||
|
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||||
|
f.putBodies["cmd/new-svc/main.go"])
|
||||||
|
// the untouched file was never written
|
||||||
|
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The /generate response omits default_branch (the live gitea behavior the old
|
||||||
|
// mock hid) → tool must re-fetch the repo and still substitute.
|
||||||
|
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
|
||||||
|
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
|
||||||
assert.Empty(t, out.PartialFailure)
|
assert.Empty(t, out.PartialFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
// Fail loud: a template whose files carry no placeholders yields nothing
|
||||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
// substituted — surface it rather than returning silent success.
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
|
||||||
gitea.NewClient("http://unused", ""),
|
files := map[string]string{"README.md": "# static, no placeholders\n"}
|
||||||
allowlist.New([]string{"mathias"}),
|
f := newFakeTemplateServer(files, "main")
|
||||||
"mathias", "template-go-web",
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
)
|
defer srv.Close()
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
assert.Empty(t, out.FilesSubstituted)
|
||||||
|
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write failure mid-pass → partial_failure populated, no Go error.
|
||||||
|
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
base := f.handler(t, "template-go-agent", "new-svc")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
assert.NotEmpty(t, out.PartialFailure)
|
||||||
|
assert.Contains(t, out.PartialFailure, "go.mod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
|
||||||
|
func TestCreateProject_DispatchAllow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
argsJSON string
|
||||||
|
wantFile bool
|
||||||
|
}{
|
||||||
|
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
|
||||||
|
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
|
||||||
|
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON)
|
||||||
|
require.Empty(t, out.PartialFailure)
|
||||||
|
if tc.wantFile {
|
||||||
|
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||||
|
assert.Contains(t, f.puts, ".dispatch-allow")
|
||||||
|
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||||
|
assert.NotContains(t, f.puts, ".dispatch-allow")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||||
|
_, err := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||||
|
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
func TestCreateProject_AllowlistRejects(t *testing.T) {
|
||||||
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
_, err := tools.NewCreateProjectFromTemplate(
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||||
gitea.NewClient("http://unused", ""),
|
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||||
allowlist.New([]string{"mathias"}),
|
|
||||||
"mathias", "template-go-web",
|
|
||||||
)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "allowlist")
|
assert.Contains(t, err.Error(), "allowlist")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
func TestCreateProject_NotTemplate(t *testing.T) {
|
||||||
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
// Template lookup returns a non-template repo.
|
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectDestinationExists: destination repo already exists.
|
func TestCreateProject_DestinationExists(t *testing.T) {
|
||||||
func TestCreateProjectDestinationExists(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
switch r.URL.Path {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
case "/api/v1/repos/mathias/template-go-agent":
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
case "/api/v1/repos/mathias/new-svc":
|
||||||
// Destination exists — return 200.
|
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
|
||||||
default:
|
default:
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrConflict)
|
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
|
|
||||||
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
|
|
||||||
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
|
|
||||||
// Files that should succeed (index 0-2 in substitutionFileList).
|
|
||||||
successFiles := map[string]bool{
|
|
||||||
"go.mod": true,
|
|
||||||
"Taskfile.yml": true,
|
|
||||||
"Dockerfile": true,
|
|
||||||
}
|
|
||||||
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
|
|
||||||
failFile := ".gitea/workflows/cd.yml"
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch {
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
|
||||||
if filePath == failFile {
|
|
||||||
// Simulate upstream 500.
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !successFiles[filePath] {
|
|
||||||
t.Errorf("unexpected PUT for file: %s", filePath)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
|
||||||
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
// Best-effort: no Go error returned, partial state in result.
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var out struct {
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
FilesSubstituted []string `json:"files_substituted"`
|
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(result, &out))
|
|
||||||
|
|
||||||
// First 3 files should be in FilesSubstituted.
|
|
||||||
assert.Len(t, out.FilesSubstituted, 3)
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
|
|
||||||
assert.NotContains(t, out.FilesSubstituted, failFile)
|
|
||||||
|
|
||||||
// partial_failure should be non-empty.
|
|
||||||
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #46: the infra#179 finalize guidance must not point at a non-existent command
|
||||||
|
// (`hyperguild new-project` was never built). It should name the real remaining
|
||||||
|
// work — substituting the placeholders — so the caller isn't sent to a dead end.
|
||||||
|
func TestInfra179FinalizeMessage(t *testing.T) {
|
||||||
|
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
|
||||||
|
|
||||||
|
for _, want := range []string{"infra#179", "__PROJECT_NAME__", "__MODULE_PATH__", "branch does not exist"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("message missing %q\ngot: %s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "hyperguild new-project") {
|
||||||
|
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DirList struct {
|
type DirList struct {
|
||||||
@@ -26,18 +26,18 @@ func (t *DirList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"ref":{"type":"string"}
|
"ref":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type dirListArgs struct {
|
type dirListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := t.c.ListContents(ctx, args.Owner, args.Name, args.Path, args.Ref)
|
entries, err := t.c.ListContents(ctx, args.Owner, args.Repo, args.Path, args.Ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileDelete struct {
|
type FileDelete struct {
|
||||||
@@ -27,20 +27,20 @@ func (t *FileDelete) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"branch":{"type":"string"},
|
"branch":{"type":"string"},
|
||||||
"message":{"type":"string"},
|
"message":{"type":"string"},
|
||||||
"sha":{"type":"string"}
|
"sha":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path","branch","message","sha"]
|
"required":["owner","repo","path","branch","message","sha"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileDeleteArgs struct {
|
type fileDeleteArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -62,7 +62,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := t.c.DeleteFile(ctx, args.Owner, args.Name, args.Path, gitea.DeleteFileArgs{
|
result, err := t.c.DeleteFile(ctx, args.Owner, args.Repo, args.Path, gitea.DeleteFileArgs{
|
||||||
Branch: args.Branch,
|
Branch: args.Branch,
|
||||||
Message: args.Message,
|
Message: args.Message,
|
||||||
Sha: args.Sha,
|
Sha: args.Sha,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const fileReadMaxBytes = 1 << 20 // 1 MiB
|
const fileReadMaxBytes = 1 << 20 // 1 MiB
|
||||||
@@ -30,18 +30,18 @@ func (t *FileRead) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"ref":{"type":"string"}
|
"ref":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path"]
|
"required":["owner","repo","path"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileReadArgs struct {
|
type fileReadArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Ref string `json:"ref"`
|
Ref string `json:"ref"`
|
||||||
}
|
}
|
||||||
@@ -58,13 +58,13 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
ref := args.Ref
|
ref := args.Ref
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
var err error
|
var err error
|
||||||
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
|
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
|
|||||||
assert.Equal(t, "main", result["ref"])
|
assert.Equal(t, "main", result["ref"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Gitea returns an array when path is a directory
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "directory")
|
||||||
|
assert.Contains(t, err.Error(), "dir_list")
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileReadAllowlistRejects(t *testing.T) {
|
func TestFileReadAllowlistRejects(t *testing.T) {
|
||||||
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileWriteBranch struct {
|
type FileWriteBranch struct {
|
||||||
@@ -23,12 +23,12 @@ func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranc
|
|||||||
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "file_write_branch",
|
Name: "file_write_branch",
|
||||||
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
|
Description: "Create or update a file on the given branch. Pass an existing branch — e.g. the default branch `main` — to commit directly to it (trunk-based); a branch that doesn't exist yet is created from `base` first (PR flow). Pair with pr_create/pr_merge for the branch→PR→merge path.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"path":{"type":"string"},
|
"path":{"type":"string"},
|
||||||
"content":{"type":"string"},
|
"content":{"type":"string"},
|
||||||
"branch":{"type":"string"},
|
"branch":{"type":"string"},
|
||||||
@@ -36,14 +36,14 @@ func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
|
|||||||
"message":{"type":"string"},
|
"message":{"type":"string"},
|
||||||
"sha":{"type":"string"}
|
"sha":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","path","content","branch","message"]
|
"required":["owner","repo","path","content","branch","message"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileWriteBranchArgs struct {
|
type fileWriteBranchArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
@@ -68,7 +68,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve base default if branch needs to be created
|
// Resolve base default if branch needs to be created
|
||||||
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
|
exists, err := t.c.BranchExists(ctx, args.Owner, args.Repo, args.Branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,18 +76,18 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
|
|||||||
base := args.Base
|
base := args.Base
|
||||||
if base == "" {
|
if base == "" {
|
||||||
var err error
|
var err error
|
||||||
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
|
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
|
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, args.Branch, base); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
|
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
|
||||||
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
|
result, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
|
||||||
Branch: args.Branch,
|
Branch: args.Branch,
|
||||||
Content: encoded,
|
Content: encoded,
|
||||||
Message: args.Message,
|
Message: args.Message,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueClose struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
|
||||||
|
return &IssueClose{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_close",
|
||||||
|
Description: "Close an open issue. Reversible via issue_reopen.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueCloseArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueCloseArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(iss)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueCloseTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
assert.JSONEq(t, `{"state":"closed"}`, string(b))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"number":26`)
|
||||||
|
assert.Contains(t, string(out), `"state":"closed"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCloseTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCloseAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueComment struct {
|
type IssueComment struct {
|
||||||
@@ -29,18 +29,18 @@ func (t *IssueComment) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1},
|
"number":{"type":"integer","minimum":1},
|
||||||
"body":{"type":"string"}
|
"body":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number","body"]
|
"required":["owner","repo","number","body"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueCommentArgs struct {
|
type issueCommentArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IssueCreate struct {
|
type IssueCreate struct {
|
||||||
@@ -29,21 +29,21 @@ func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"title":{"type":"string"},
|
"title":{"type":"string"},
|
||||||
"body":{"type":"string"},
|
"body":{"type":"string"},
|
||||||
"labels":{"type":"array","items":{"type":"integer"}},
|
"labels":{"type":"array","items":{"type":"integer"}},
|
||||||
"assignees":{"type":"array","items":{"type":"string"}},
|
"assignees":{"type":"array","items":{"type":"string"}},
|
||||||
"milestone":{"type":"integer"}
|
"milestone":{"type":"integer"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","title"]
|
"required":["owner","repo","title"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueCreateArgs struct {
|
type issueCreateArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Labels []int64 `json:"labels"`
|
Labels []int64 `json:"labels"`
|
||||||
@@ -64,7 +64,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
|
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Repo, gitea.CreateIssueArgs{
|
||||||
Title: args.Title,
|
Title: args.Title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Labels: args.Labels,
|
Labels: args.Labels,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueEdit struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit {
|
||||||
|
return &IssueEdit{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_edit",
|
||||||
|
Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " +
|
||||||
|
"omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " +
|
||||||
|
"WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " +
|
||||||
|
"(fetch with issue_get, edit the text, send it back).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
|
||||||
|
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueEditArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueEditArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Title == nil && args.Body == nil {
|
||||||
|
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss, err := t.c.EditIssue(ctx, args.Owner, args.Repo, args.Number, gitea.EditIssueArgs{
|
||||||
|
Title: args.Title,
|
||||||
|
Body: args.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"number": iss.Number,
|
||||||
|
"title": iss.Title,
|
||||||
|
"html_url": iss.HTMLURL,
|
||||||
|
"state": iss.State,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueEditTool(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "new", payload["title"])
|
||||||
|
assert.Equal(t, "updated body", payload["body"])
|
||||||
|
|
||||||
|
assert.Contains(t, string(out), `"number":26`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body must be sent verbatim — no identity footer appended (keeps edits idempotent).
|
||||||
|
func TestIssueEditTool_BodyVerbatim(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer")
|
||||||
|
_, hasTitle := payload["title"]
|
||||||
|
assert.False(t, hasTitle, "title must be omitted when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditTool_RequiresAField(t *testing.T) {
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
title := "x"
|
||||||
|
body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title})
|
||||||
|
_, err := tool.Call(context.Background(), body)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueGet struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
|
||||||
|
|
||||||
|
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_get",
|
||||||
|
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueGetArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueGetArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(iss)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueGetTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"number":42`)
|
||||||
|
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||||
|
assert.Contains(t, string(out), `"comments":3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueLabel struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
|
||||||
|
return &IssueLabel{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_label",
|
||||||
|
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"labels":{"type":"array","items":{"type":"string"}},
|
||||||
|
"label_ids":{"type":"array","items":{"type":"integer"}}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number","labels"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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,82 @@
|
|||||||
|
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"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(labelListFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
assert.Contains(t, err.Error(), `"does-not-exist"`)
|
||||||
|
assert.Contains(t, err.Error(), "o/r")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelRequiresValidNumber(t *testing.T) {
|
||||||
|
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueList struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
|
||||||
|
return &IssueList{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueList) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_list",
|
||||||
|
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"state":{"type":"string","enum":["open","closed","all"]},
|
||||||
|
"labels":{"type":"string"},
|
||||||
|
"since":{"type":"string"},
|
||||||
|
"page":{"type":"integer","minimum":1},
|
||||||
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
|
},
|
||||||
|
"required":["owner","repo"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueListArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Labels string `json:"labels"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueListArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.State == "" {
|
||||||
|
args.State = "open"
|
||||||
|
}
|
||||||
|
args.Limit = capLimit(args.Limit, 30)
|
||||||
|
if args.Page < 1 {
|
||||||
|
args.Page = 1
|
||||||
|
}
|
||||||
|
issues, err := t.c.ListIssues(ctx, args.Owner, args.Repo, gitea.ListIssuesArgs{
|
||||||
|
State: args.State,
|
||||||
|
Labels: args.Labels,
|
||||||
|
Since: args.Since,
|
||||||
|
Page: args.Page,
|
||||||
|
Limit: args.Limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
if len(issues) == args.Limit {
|
||||||
|
out["next_page"] = args.Page + 1
|
||||||
|
}
|
||||||
|
return textOK(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueListComments struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueListComments(c *gitea.Client, a *allowlist.Allowlist) *IssueListComments {
|
||||||
|
return &IssueListComments{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_list_comments",
|
||||||
|
Description: "List all comments on an issue or pull request. Returns id, body, author, html_url, and timestamps for each comment.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueListCommentsArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueListCommentsArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(comments)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueListCommentsTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42/comments", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"body":"first","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||||
|
{"id":2,"body":"second","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"id":1`)
|
||||||
|
assert.Contains(t, string(out), `"body":"first"`)
|
||||||
|
assert.Contains(t, string(out), `"login":"alice"`)
|
||||||
|
assert.Contains(t, string(out), `"login":"bob"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsTool_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `[]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueListTool(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantQuery map[string]string
|
||||||
|
respBody string
|
||||||
|
assert func(t *testing.T, out string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "happy path defaults",
|
||||||
|
input: `{"owner":"mathias","name":"infra"}`,
|
||||||
|
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
|
||||||
|
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
|
||||||
|
assert: func(t *testing.T, out string) {
|
||||||
|
assert.Contains(t, out, `"number":42`)
|
||||||
|
assert.Contains(t, out, `"number":41`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state filter",
|
||||||
|
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
|
||||||
|
wantQuery: map[string]string{"type": "issues", "state": "closed"},
|
||||||
|
respBody: `[]`,
|
||||||
|
assert: func(t *testing.T, out string) {
|
||||||
|
assert.Contains(t, out, `"issues":[]`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "label + since filter",
|
||||||
|
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
|
||||||
|
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
|
||||||
|
respBody: `[]`,
|
||||||
|
assert: func(t *testing.T, out string) {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty result",
|
||||||
|
input: `{"owner":"mathias","name":"infra"}`,
|
||||||
|
wantQuery: map[string]string{"state": "open"},
|
||||||
|
respBody: `[]`,
|
||||||
|
assert: func(t *testing.T, out string) {
|
||||||
|
assert.Contains(t, out, `"issues":[]`)
|
||||||
|
assert.NotContains(t, out, `next_page`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
|
||||||
|
q := r.URL.Query()
|
||||||
|
for k, v := range tc.wantQuery {
|
||||||
|
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(tc.respBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||||
|
require.NoError(t, err)
|
||||||
|
tc.assert(t, string(out))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueReopen struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
|
||||||
|
return &IssueReopen{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_reopen",
|
||||||
|
Description: "Reopen a closed issue. Reversible via issue_close.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","repo","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueReopenArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueReopenArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(iss)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueReopenTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
assert.JSONEq(t, `{"state":"open"}`, string(b))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"number":26`)
|
||||||
|
assert.Contains(t, string(out), `"state":"open"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueReopenAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LabelList struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
|
||||||
|
return &LabelList{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LabelList) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "label_list",
|
||||||
|
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"repo":{"type":"string"}
|
||||||
|
},
|
||||||
|
"required":["owner","repo"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelListArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args labelListArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(labels)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLabelListTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/labels", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"name":"bug","color":"ee0701"},
|
||||||
|
{"id":2,"name":"enhancement","color":"84b6eb"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"id":1`)
|
||||||
|
assert.Contains(t, string(out), `"name":"bug"`)
|
||||||
|
assert.Contains(t, string(out), `"color":"ee0701"`)
|
||||||
|
assert.Contains(t, string(out), `"name":"enhancement"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelListAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRComment struct {
|
type PRComment struct {
|
||||||
@@ -29,18 +29,18 @@ func (t *PRComment) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1},
|
"number":{"type":"integer","minimum":1},
|
||||||
"body":{"type":"string"}
|
"body":{"type":"string"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number","body"]
|
"required":["owner","repo","number","body"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prCommentArgs struct {
|
type prCommentArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
|
|||||||
}
|
}
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
|
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Repo, args.Number, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRCreate struct {
|
type PRCreate struct {
|
||||||
@@ -29,21 +29,21 @@ func (t *PRCreate) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"title":{"type":"string"},
|
"title":{"type":"string"},
|
||||||
"body":{"type":"string"},
|
"body":{"type":"string"},
|
||||||
"head":{"type":"string"},
|
"head":{"type":"string"},
|
||||||
"base":{"type":"string"},
|
"base":{"type":"string"},
|
||||||
"draft":{"type":"boolean"}
|
"draft":{"type":"boolean"}
|
||||||
},
|
},
|
||||||
"required":["owner","name","title","head","base"]
|
"required":["owner","repo","title","head","base"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prCreateArgs struct {
|
type prCreateArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Head string `json:"head"`
|
Head string `json:"head"`
|
||||||
@@ -68,7 +68,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
|
|||||||
|
|
||||||
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
|
||||||
|
|
||||||
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Name, gitea.CreatePullRequestArgs{
|
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Repo, gitea.CreatePullRequestArgs{
|
||||||
Title: args.Title,
|
Title: args.Title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Head: args.Head,
|
Head: args.Head,
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -30,7 +30,7 @@ const prFixture = `{
|
|||||||
|
|
||||||
func callerContext(user string) context.Context {
|
func callerContext(user string) context.Context {
|
||||||
var capturedCtx context.Context
|
var capturedCtx context.Context
|
||||||
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
h := auth.CallerMiddleware(nil, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
capturedCtx = r.Context()
|
capturedCtx = r.Context()
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -35,17 +35,17 @@ func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prFilesDiffArgs struct {
|
type prFilesDiffArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +70,12 @@ func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
|
|||||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
|
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Repo, args.Number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
|
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Repo, args.Number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
|||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
if currentFile != "" {
|
if currentFile != "" {
|
||||||
m[currentFile] = current.Bytes()
|
// Copy: bytes.Buffer.Bytes() returns the internal slice,
|
||||||
|
// which Reset() then reuses. Without the copy, every map
|
||||||
|
// entry ends up aliased to the last file's data.
|
||||||
|
b := current.Bytes()
|
||||||
|
cp := make([]byte, len(b))
|
||||||
|
copy(cp, b)
|
||||||
|
m[currentFile] = cp
|
||||||
current.Reset()
|
current.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -97,6 +97,47 @@ func TestPRFilesDiffSmall(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, fileNames, paths)
|
assert.ElementsMatch(t, fileNames, paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
||||||
|
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
||||||
|
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
||||||
|
// aliased the buffer's backing array and showed the last file's content.
|
||||||
|
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
||||||
|
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
||||||
|
rawDiff := buildDiff(fileNames, 5)
|
||||||
|
filesJSON := buildFilesJSON(fileNames, 5)
|
||||||
|
|
||||||
|
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
require.Len(t, out.Files, len(fileNames))
|
||||||
|
|
||||||
|
for _, f := range out.Files {
|
||||||
|
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
||||||
|
assert.Contains(t, f.Diff, expected,
|
||||||
|
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
||||||
|
// No other file's header should leak in.
|
||||||
|
for _, other := range fileNames {
|
||||||
|
if other == f.Path {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
||||||
|
assert.NotContains(t, f.Diff, otherHeader,
|
||||||
|
"file %s diff must NOT contain %s's header", f.Path, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||||
fileNames := []string{"bigfile.go"}
|
fileNames := []string{"bigfile.go"}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRGet struct {
|
type PRGet struct {
|
||||||
@@ -25,17 +25,17 @@ func (t *PRGet) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"number":{"type":"integer","minimum":1}
|
"number":{"type":"integer","minimum":1}
|
||||||
},
|
},
|
||||||
"required":["owner","name","number"]
|
"required":["owner","repo","number"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prGetArgs struct {
|
type prGetArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
|
|||||||
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Name, args.Number)
|
pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Repo, args.Number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRList struct {
|
type PRList struct {
|
||||||
@@ -26,20 +26,20 @@ func (t *PRList) Descriptor() registry.ToolDescriptor {
|
|||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string"},
|
"repo":{"type":"string"},
|
||||||
"state":{"type":"string","enum":["open","closed","all"]},
|
"state":{"type":"string","enum":["open","closed","all"]},
|
||||||
"head":{"type":"string"},
|
"head":{"type":"string"},
|
||||||
"page":{"type":"integer","minimum":1},
|
"page":{"type":"integer","minimum":1},
|
||||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","repo"]
|
||||||
}`),
|
}`),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type prListArgs struct {
|
type prListArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Repo string `json:"repo"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Head string `json:"head"`
|
Head string `json:"head"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
@@ -59,7 +59,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
|
|||||||
state = "open"
|
state = "open"
|
||||||
}
|
}
|
||||||
|
|
||||||
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Name, state, args.Head, args.Page, capLimit(args.Limit, 30))
|
prs, err := t.c.ListPullRequests(ctx, args.Owner, args.Repo, state, args.Head, args.Page, capLimit(args.Limit, 30))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user