Compare commits
25 Commits
174669b9f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bea0d2f27 | ||
|
|
24c353383f | ||
|
|
be85baf410 | ||
|
|
658f4ba84f | ||
|
|
60212fc5d2 | ||
|
|
dc907fb7e0 | ||
|
|
c4bd3396c4 | ||
|
|
11f86f5d99 | ||
|
|
f7076c9ac8 | ||
| e31fd3f023 | |||
|
|
3cccbfb8cb | ||
| 3648373333 | |||
|
|
eeefc626ed | ||
|
|
5545d6ab4b | ||
|
|
9013c8ff9c | ||
|
|
f26f922c96 | ||
| a414222610 | |||
| 3b490271ef | |||
|
|
d4dddbdb6c | ||
| a69d3a8b76 | |||
|
|
5f3ad99122 | ||
| 2c94de7b59 | |||
|
|
e2da495581 | ||
|
|
7178ae32be | ||
| cb4f0caf0b |
@@ -36,9 +36,18 @@ 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.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -49,9 +58,10 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
@@ -61,9 +71,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc 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
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -71,7 +84,7 @@ Three machines on Tailscale:
|
||||
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
@@ -103,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **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`
|
||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||
name once hyperguild is deployed. Until then, agents that try to
|
||||
reach the knowledge service on a host where it isn't running will
|
||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
@@ -211,8 +270,11 @@ Key skills:
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -250,4 +312,29 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
@@ -37,8 +37,11 @@
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -76,4 +79,29 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
},
|
||||
"infra": {
|
||||
"type": "http",
|
||||
"url": "https://infra-mcp.d-ma.be/mcp"
|
||||
"url": "https://infra-mcp.d-ma.be/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,18 @@ 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.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -54,9 +63,10 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
@@ -66,9 +76,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc 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
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -76,7 +89,7 @@ Three machines on Tailscale:
|
||||
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
@@ -108,18 +121,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **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`
|
||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||
name once hyperguild is deployed. Until then, agents that try to
|
||||
reach the knowledge service on a host where it isn't running will
|
||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
@@ -216,8 +275,11 @@ Key skills:
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -255,6 +317,31 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
125
.cursorrules
125
.cursorrules
@@ -39,9 +39,18 @@ 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.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -52,9 +61,10 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
@@ -64,9 +74,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc 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
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -74,7 +87,7 @@ Three machines on Tailscale:
|
||||
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
@@ -106,18 +119,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **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`
|
||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||
name once hyperguild is deployed. Until then, agents that try to
|
||||
reach the knowledge service on a host where it isn't running will
|
||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
@@ -214,8 +273,11 @@ Key skills:
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -253,4 +315,29 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: gitea-mcp
|
||||
@@ -43,7 +41,6 @@ jobs:
|
||||
name: Build & Import
|
||||
needs: check
|
||||
runs-on: self-hosted
|
||||
if: github.event_name != 'pull_request'
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||
steps:
|
||||
|
||||
5
.githooks/pre-push
Normal file
5
.githooks/pre-push
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "→ Running task check before push..."
|
||||
task check
|
||||
echo "✓ pre-push check passed"
|
||||
125
AGENTS.md
125
AGENTS.md
@@ -36,9 +36,18 @@ 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.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -49,9 +58,10 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
|
||||
@@ -61,9 +71,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||
- **Naming**: stdlib conventions, no stuttering
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
one logical change per commit, CI is the quality gate
|
||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||
passing `task check` locally first
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc 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
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -71,7 +84,7 @@ Three machines on Tailscale:
|
||||
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
@@ -103,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||
- **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`
|
||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||
### When to query (treat as a reflex)
|
||||
|
||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||
name once hyperguild is deployed. Until then, agents that try to
|
||||
reach the knowledge service on a host where it isn't running will
|
||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||
- **When debugging** — search for the error string, the stack frame, the affected
|
||||
service. Past you may have already paid this tax.
|
||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||
was tried and rejected, or what the integration footguns are.
|
||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||
"decision" to find prior reasoning before re-deriving it.
|
||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||
documented?" The brain often has it.
|
||||
|
||||
### When to write
|
||||
|
||||
After you discover something that **future-you would forget** and that **isn't
|
||||
recoverable from the code, git log, or PR description alone**:
|
||||
|
||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||
|
||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
|
||||
### How to access (per harness)
|
||||
|
||||
| Harness | Query | Write |
|
||||
|---------|-------|-------|
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||
berget URL (see knowledge entry on namespace mismatches).
|
||||
|
||||
### Quick reflex checks
|
||||
|
||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
||||
|
||||
- "I think the issue might be..."
|
||||
- "Let me try X and see..."
|
||||
- "I'll just write a script to..."
|
||||
- "This is probably a new bug..."
|
||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
||||
|
||||
## Client work rules
|
||||
|
||||
@@ -211,8 +270,11 @@ Key skills:
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -250,4 +312,29 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@@ -37,8 +37,11 @@
|
||||
|
||||
### Git
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||
- PRs: one concern per PR, description explains *why* not *what*
|
||||
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||
- No feature branches, no PRs for solo/agent work.
|
||||
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||
`agent/<description>` branch and merge within the same session.
|
||||
|
||||
### Security
|
||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||
@@ -76,4 +79,29 @@ When acting as a coding agent on this project:
|
||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||
4. Never modify files outside the project root without explicit permission
|
||||
5. When adding a dependency, explain why in the commit message
|
||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||
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.
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,5 +1,16 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going
|
||||
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||
# the module proxy + sumdb.
|
||||
RUN apk add --no-cache git && \
|
||||
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
||||
ENV GOPRIVATE=gitea.d-ma.be
|
||||
ENV GOPROXY=direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
11
README.md
11
README.md
@@ -2,3 +2,14 @@
|
||||
|
||||
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
||||
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
task setup:hooks # installs .githooks/pre-push — runs task check before every push
|
||||
task check # context sync + lint + test + vet
|
||||
task build # produces bin/gitea-mcp
|
||||
```
|
||||
|
||||
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
|
||||
hook enforces the quality gate locally; CI re-runs `task check` on every push.
|
||||
|
||||
@@ -47,6 +47,13 @@ tasks:
|
||||
cmds:
|
||||
- bash scripts/context-sync.sh
|
||||
|
||||
setup:hooks:
|
||||
desc: Install git hooks (.githooks/pre-push)
|
||||
cmds:
|
||||
- git config core.hooksPath .githooks
|
||||
- chmod +x .githooks/pre-push
|
||||
- echo "✓ git hooks installed (pre-push runs task check)"
|
||||
|
||||
context:sync:claude:
|
||||
cmds: [bash scripts/context-sync.sh claude]
|
||||
context:sync:agents:
|
||||
|
||||
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
@@ -27,7 +29,7 @@ func main() {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||
if err != nil {
|
||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
||||
}
|
||||
@@ -60,15 +62,35 @@ func main() {
|
||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueClose(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||
|
||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||
Registry: reg,
|
||||
Sessions: mcp.NewSessionStore(),
|
||||
})
|
||||
|
||||
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
||||
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
||||
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
||||
var resourceMetadataURL string
|
||||
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||
auth.CallerMiddleware(mcpSrv),
|
||||
),
|
||||
))
|
||||
@@ -76,21 +98,10 @@ func main() {
|
||||
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 != "" {
|
||||
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
|
||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
||||
|
||||
1
go.mod
1
go.mod
@@ -9,6 +9,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Array response means path is a directory — guide caller to dir_list.
|
||||
if len(body) > 0 && body[0] == '[' {
|
||||
return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path)
|
||||
}
|
||||
var fc FileContents
|
||||
if err := json.Unmarshal(body, &fc); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
@@ -12,6 +14,20 @@ type Issue struct {
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
State string `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Labels []Label `json:"labels"`
|
||||
Assignees []User `json:"assignees"`
|
||||
Comments int `json:"comments"`
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type CreateIssueArgs struct {
|
||||
@@ -22,6 +38,22 @@ type CreateIssueArgs struct {
|
||||
Milestone int64 `json:"milestone,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
payload, err := json.Marshal(args)
|
||||
@@ -42,6 +74,72 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// ListIssuesArgs captures the optional query params for ListIssues.
|
||||
type ListIssuesArgs struct {
|
||||
State string // "open" | "closed" | "all"
|
||||
Labels string // comma-separated label names
|
||||
Since string // ISO 8601
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListIssues fetches issues for a repo. Pulls are excluded server-side
|
||||
// (type=issues) so they don't leak through the same endpoint.
|
||||
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "issues")
|
||||
if args.State != "" {
|
||||
q.Set("state", args.State)
|
||||
}
|
||||
if args.Labels != "" {
|
||||
q.Set("labels", args.Labels)
|
||||
}
|
||||
if args.Since != "" {
|
||||
q.Set("since", args.Since)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var issues []Issue
|
||||
if err := json.Unmarshal(body, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// SetIssueState flips an issue between "open" and "closed" via PATCH.
|
||||
// Gitea uses the same endpoint for both transitions.
|
||||
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||
payload, err := json.Marshal(map[string]string{"state": state})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var iss Issue
|
||||
if err := json.Unmarshal(body, &iss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
type IssueComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
|
||||
@@ -45,6 +45,37 @@ func TestCreateIssue(t *testing.T) {
|
||||
assert.Equal(t, "open", iss.State)
|
||||
}
|
||||
|
||||
func TestGetIssue(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://example.com/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
iss, err := c.GetIssue(context.Background(), "o", "r", 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 42, iss.Number)
|
||||
assert.Equal(t, "fix auth", iss.Title)
|
||||
assert.Equal(t, "open", iss.State)
|
||||
assert.Equal(t, 3, iss.Comments)
|
||||
}
|
||||
|
||||
func TestGetIssue_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
_, err := c.GetIssue(context.Background(), "o", "r", 999)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestCreateIssueComment(t *testing.T) {
|
||||
var captured []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
71
internal/gitea/mirrors.go
Normal file
71
internal/gitea/mirrors.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type PushMirror struct {
|
||||
ID int `json:"id"`
|
||||
RemoteName string `json:"remote_name"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
}
|
||||
|
||||
type AddPushMirrorArgs struct {
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
RemoteUsername string `json:"remote_username,omitempty"`
|
||||
RemotePassword string `json:"remote_password,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
SyncOnCommit bool `json:"sync_on_commit,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) AddPushMirror(ctx context.Context, owner, repo string, args AddPushMirrorArgs) (*PushMirror, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m PushMirror
|
||||
if err := json.Unmarshal(resp, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListPushMirrors(ctx context.Context, owner, repo string) ([]PushMirror, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner, repo)
|
||||
resp, status, err := c.GetJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mirrors []PushMirror
|
||||
if err := json.Unmarshal(resp, &mirrors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mirrors, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePushMirror(ctx context.Context, owner, repo, mirrorName string) error {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors/%s", owner, repo, mirrorName)
|
||||
resp, status, err := c.DeleteJSON(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == 204 {
|
||||
return nil
|
||||
}
|
||||
return MapStatus(status, resp)
|
||||
}
|
||||
64
internal/gitea/mirrors_test.go
Normal file
64
internal/gitea/mirrors_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddPushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
m, err := c.AddPushMirror(context.Background(), "mathias", "infra", gitea.AddPushMirrorArgs{
|
||||
RemoteAddress: "https://github.com/mathias/infra.git",
|
||||
RemoteUsername: "mathias",
|
||||
RemotePassword: "secret",
|
||||
Interval: "8h0m0s",
|
||||
SyncOnCommit: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mirror-github", m.RemoteName)
|
||||
assert.Equal(t, "https://github.com/mathias/infra.git", m.RemoteAddress)
|
||||
}
|
||||
|
||||
func TestListPushMirrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
mirrors, err := c.ListPushMirrors(context.Background(), "mathias", "infra")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mirrors, 1)
|
||||
assert.Equal(t, "mirror-github", mirrors[0].RemoteName)
|
||||
}
|
||||
|
||||
func TestDeletePushMirror(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.DeletePushMirror(context.Background(), "mathias", "infra", "mirror-github")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -18,6 +18,109 @@ type Repo struct {
|
||||
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) {
|
||||
if 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
|
||||
}
|
||||
|
||||
type CreateRepoArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
AutoInit bool `json:"auto_init,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
// Org, when non-empty, creates the repo under the named organisation.
|
||||
// Uses POST /api/v1/orgs/{org}/repos instead of /api/v1/user/repos.
|
||||
Org string `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, args CreateRepoArgs) (*Repo, error) {
|
||||
var path string
|
||||
if args.Org != "" {
|
||||
path = fmt.Sprintf("/api/v1/orgs/%s/repos", args.Org)
|
||||
} else {
|
||||
path = "/api/v1/user/repos"
|
||||
}
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PostJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateRepoArgs uses pointers so omitempty can distinguish "not set" from false/zero.
|
||||
type UpdateRepoArgs struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Template *bool `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||
body, status, err := c.GetJSON(ctx, path)
|
||||
@@ -86,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,129 @@ func TestListRepos(t *testing.T) {
|
||||
assert.Equal(t, "main", repos[0].DefaultBranch)
|
||||
}
|
||||
|
||||
func TestCreateRepo_User(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||
Name: "infra",
|
||||
Private: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mathias/infra", r.FullName)
|
||||
assert.Equal(t, "main", r.DefaultBranch)
|
||||
}
|
||||
|
||||
func TestCreateRepo_Org(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||
Name: "infra",
|
||||
Org: "hyperguild",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||
}
|
||||
|
||||
func TestUpdateRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":false,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
desc := "updated"
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
r, err := c.UpdateRepo(context.Background(), "mathias", "infra", gitea.UpdateRepoArgs{
|
||||
Description: &desc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated", r.Description)
|
||||
}
|
||||
|
||||
func TestGetTree(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc", tree.SHA)
|
||||
require.Len(t, tree.Tree, 2)
|
||||
assert.Equal(t, "README.md", tree.Tree[0].Path)
|
||||
assert.Equal(t, "blob", tree.Tree[0].Type)
|
||||
assert.Equal(t, int64(13), tree.Tree[0].Size)
|
||||
}
|
||||
|
||||
func TestUpdateTopics(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPut, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRelease(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"first release","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
rel, err := c.CreateRelease(context.Background(), "mathias", "infra", gitea.CreateReleaseArgs{
|
||||
TagName: "v1.0.0",
|
||||
Name: "v1.0.0",
|
||||
Body: "first release",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1.0.0", rel.TagName)
|
||||
assert.Equal(t, "first release", rel.Body)
|
||||
}
|
||||
|
||||
func TestDeleteRepo(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
err := c.DeleteRepo(context.Background(), "mathias", "infra")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -55,10 +56,20 @@ func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow str
|
||||
// WorkflowRun represents a Gitea Actions run.
|
||||
type WorkflowRun struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title,omitempty"`
|
||||
Status string `json:"status"` // queued | in_progress | completed
|
||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||
Event string `json:"event,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
HeadBranch string `json:"head_branch,omitempty"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
RunNumber int64 `json:"run_number,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
// GetWorkflowRun fetches the status of a specific Actions run.
|
||||
@@ -77,3 +88,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
|
||||
}
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
|
||||
type ListWorkflowRunsArgs struct {
|
||||
Branch string
|
||||
HeadSHA string
|
||||
Status string // queued | in_progress | completed | all
|
||||
Event string // push | pull_request | schedule | workflow_dispatch | all
|
||||
Workflow string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type workflowRunsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
|
||||
}
|
||||
|
||||
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
|
||||
// Status / Event of "all" or "" are treated as no-filter.
|
||||
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
|
||||
q := url.Values{}
|
||||
if args.Branch != "" {
|
||||
q.Set("branch", args.Branch)
|
||||
}
|
||||
if args.HeadSHA != "" {
|
||||
q.Set("head_sha", args.HeadSHA)
|
||||
}
|
||||
if args.Status != "" && args.Status != "all" {
|
||||
q.Set("status", args.Status)
|
||||
}
|
||||
if args.Event != "" && args.Event != "all" {
|
||||
q.Set("event", args.Event)
|
||||
}
|
||||
if args.Workflow != "" {
|
||||
q.Set("workflow", args.Workflow)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp workflowRunsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -45,14 +45,15 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
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, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean"}
|
||||
"private":{"type":"boolean"},
|
||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
@@ -64,6 +65,7 @@ type createProjectArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
TemplateName string `json:"template_name"`
|
||||
}
|
||||
|
||||
type createProjectResult struct {
|
||||
@@ -91,13 +93,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)
|
||||
}
|
||||
|
||||
// Resolve template: per-call override takes precedence over the
|
||||
// server-configured default. Owner stays server-configured.
|
||||
tmplName := args.TemplateName
|
||||
if tmplName == "" {
|
||||
tmplName = t.templateName
|
||||
}
|
||||
|
||||
// Verify template exists and is marked as a template repo.
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template lookup: %w", err)
|
||||
}
|
||||
if !tmpl.Template {
|
||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, 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.
|
||||
@@ -108,7 +117,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
}
|
||||
|
||||
// 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,
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
|
||||
@@ -122,6 +122,62 @@ func TestCreateProjectHappyPath(t *testing.T) {
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
}
|
||||
|
||||
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
||||
// server-configured default, so the same binary can generate from template-go-web or
|
||||
// template-go-agent without restart.
|
||||
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
||||
var templateLookups, generateCalls []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
|
||||
templateLookups = append(templateLookups, "template-go-agent")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
templateLookups = append(templateLookups, "template-go-web")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
||||
generateCalls = append(generateCalls, r.URL.Path)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
||||
"override must direct the template lookup, not the server default")
|
||||
require.Len(t, generateCalls, 1)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
||||
"override must direct the /generate call too")
|
||||
}
|
||||
|
||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||
tool := tools.NewCreateProjectFromTemplate(
|
||||
|
||||
@@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) {
|
||||
assert.Equal(t, "main", result["ref"])
|
||||
}
|
||||
|
||||
func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Gitea returns an array when path is a directory
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "directory")
|
||||
assert.Contains(t, err.Error(), "dir_list")
|
||||
}
|
||||
|
||||
func TestFileReadAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
|
||||
|
||||
56
internal/tools/issue_close.go
Normal file
56
internal/tools/issue_close.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueClose struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
|
||||
return &IssueClose{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_close",
|
||||
Description: "Close an open issue. Reversible via issue_reopen.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueCloseArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueCloseArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "closed")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
52
internal/tools/issue_close_test.go
Normal file
52
internal/tools/issue_close_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueCloseTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
assert.JSONEq(t, `{"state":"closed"}`, string(b))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":26`)
|
||||
assert.Contains(t, string(out), `"state":"closed"`)
|
||||
}
|
||||
|
||||
func TestIssueCloseTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueCloseAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
54
internal/tools/issue_get.go
Normal file
54
internal/tools/issue_get.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueGet struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueGet(c *gitea.Client, a *allowlist.Allowlist) *IssueGet { return &IssueGet{c: c, a: a} }
|
||||
|
||||
func (t *IssueGet) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_get",
|
||||
Description: "Get a single issue by number, including body, state, labels, assignees, and comment count.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueGetArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueGetArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.GetIssue(ctx, args.Owner, args.Name, args.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
50
internal/tools/issue_get_test.go
Normal file
50
internal/tools/issue_get_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueGetTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"title":"fix auth","body":"details","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/42","created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-02T00:00:00Z","comments":3}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":42`)
|
||||
assert.Contains(t, string(out), `"title":"fix auth"`)
|
||||
assert.Contains(t, string(out), `"comments":3`)
|
||||
}
|
||||
|
||||
func TestIssueGetTool_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssueGetAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
83
internal/tools/issue_list.go
Normal file
83
internal/tools/issue_list.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
|
||||
return &IssueList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_list",
|
||||
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"state":{"type":"string","enum":["open","closed","all"]},
|
||||
"labels":{"type":"string"},
|
||||
"since":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Labels string `json:"labels"`
|
||||
Since string `json:"since"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.State == "" {
|
||||
args.State = "open"
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, gitea.ListIssuesArgs{
|
||||
State: args.State,
|
||||
Labels: args.Labels,
|
||||
Since: args.Since,
|
||||
Page: args.Page,
|
||||
Limit: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"issues": issues,
|
||||
}
|
||||
if len(issues) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
88
internal/tools/issue_list_test.go
Normal file
88
internal/tools/issue_list_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
|
||||
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"number":42`)
|
||||
assert.Contains(t, out, `"number":41`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state filter",
|
||||
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "closed"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "label + since filter",
|
||||
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
|
||||
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "empty result",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"state": "open"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
assert.NotContains(t, out, `next_page`)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||
require.NoError(t, err)
|
||||
tc.assert(t, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
56
internal/tools/issue_reopen.go
Normal file
56
internal/tools/issue_reopen.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueReopen struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
|
||||
return &IssueReopen{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_reopen",
|
||||
Description: "Reopen a closed issue. Reversible via issue_close.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"number":{"type":"integer","minimum":1}
|
||||
},
|
||||
"required":["owner","name","number"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueReopenArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueReopenArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "open")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(iss)
|
||||
}
|
||||
40
internal/tools/issue_reopen_test.go
Normal file
40
internal/tools/issue_reopen_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueReopenTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
assert.JSONEq(t, `{"state":"open"}`, string(b))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"number":26`)
|
||||
assert.Contains(t, string(out), `"state":"open"`)
|
||||
}
|
||||
|
||||
func TestIssueReopenAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
||||
|
||||
flush := func() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,47 @@ func TestPRFilesDiffSmall(t *testing.T) {
|
||||
assert.ElementsMatch(t, fileNames, paths)
|
||||
}
|
||||
|
||||
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
||||
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
||||
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
||||
// aliased the buffer's backing array and showed the last file's content.
|
||||
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
||||
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
||||
rawDiff := buildDiff(fileNames, 5)
|
||||
filesJSON := buildFilesJSON(fileNames, 5)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
require.Len(t, out.Files, len(fileNames))
|
||||
|
||||
for _, f := range out.Files {
|
||||
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
||||
assert.Contains(t, f.Diff, expected,
|
||||
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
||||
// No other file's header should leak in.
|
||||
for _, other := range fileNames {
|
||||
if other == f.Path {
|
||||
continue
|
||||
}
|
||||
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
||||
assert.NotContains(t, f.Diff, otherHeader,
|
||||
"file %s diff must NOT contain %s's header", f.Path, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||
fileNames := []string{"bigfile.go"}
|
||||
|
||||
73
internal/tools/release_create.go
Normal file
73
internal/tools/release_create.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type ReleaseCreate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewReleaseCreate(c *gitea.Client, a *allowlist.Allowlist) *ReleaseCreate {
|
||||
return &ReleaseCreate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *ReleaseCreate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "release_create",
|
||||
Description: "Create a release (and tag if it doesn't exist) for a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"tag_name":{"type":"string","description":"Tag to create or use, e.g. 'v1.0.0'."},
|
||||
"release_name":{"type":"string","description":"Display name for the release."},
|
||||
"body":{"type":"string","description":"Release notes / changelog."},
|
||||
"draft":{"type":"boolean"},
|
||||
"prerelease":{"type":"boolean"},
|
||||
"target":{"type":"string","description":"Branch or commit SHA to tag. Defaults to repo default branch."}
|
||||
},
|
||||
"required":["owner","name","tag_name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type releaseCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args releaseCreateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Name, gitea.CreateReleaseArgs{
|
||||
TagName: args.TagName,
|
||||
Name: args.ReleaseName,
|
||||
Body: args.Body,
|
||||
Draft: args.Draft,
|
||||
Prerelease: args.Prerelease,
|
||||
Target: args.Target,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(rel)
|
||||
}
|
||||
38
internal/tools/release_create_test.go
Normal file
38
internal/tools/release_create_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReleaseCreateTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/releases", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.0.0","name":"v1.0.0","body":"changelog","draft":false,"prerelease":false,"html_url":"https://gitea.example.com/mathias/infra/releases/tag/v1.0.0","created_at":"2026-05-15T00:00:00Z"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewReleaseCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","tag_name":"v1.0.0","release_name":"v1.0.0","body":"changelog"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"tag_name":"v1.0.0"`)
|
||||
assert.Contains(t, string(out), `"html_url"`)
|
||||
}
|
||||
|
||||
func TestReleaseCreateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewReleaseCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","tag_name":"v1.0.0"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
74
internal/tools/repo_create.go
Normal file
74
internal/tools/repo_create.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoCreate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoCreate(c *gitea.Client, a *allowlist.Allowlist) *RepoCreate {
|
||||
return &RepoCreate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoCreate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_create",
|
||||
Description: "Create a repository for the authenticated user or an organisation.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string","description":"Username or org name (used for allowlist check)."},
|
||||
"name":{"type":"string","description":"Repository name."},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean","description":"Create as private. Default false."},
|
||||
"auto_init":{"type":"boolean","description":"Initialise with README."},
|
||||
"default_branch":{"type":"string","description":"Default branch name. Default 'main'."},
|
||||
"is_org":{"type":"boolean","description":"When true, create under the organisation named in 'owner'."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoCreateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
IsOrg bool `json:"is_org"`
|
||||
}
|
||||
|
||||
func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoCreateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createArgs := gitea.CreateRepoArgs{
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
AutoInit: args.AutoInit,
|
||||
DefaultBranch: args.DefaultBranch,
|
||||
}
|
||||
if args.IsOrg {
|
||||
createArgs.Org = args.Owner
|
||||
}
|
||||
r, err := t.c.CreateRepo(ctx, createArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(r)
|
||||
}
|
||||
53
internal/tools/repo_create_test.go
Normal file
53
internal/tools/repo_create_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoCreateTool_User(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/user/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":true}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
|
||||
assert.Contains(t, string(out), `"clone_url"`)
|
||||
}
|
||||
|
||||
func TestRepoCreateTool_Org(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/orgs/hyperguild/repos", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"infra","full_name":"hyperguild/infra","default_branch":"main","private":false,"clone_url":"https://gitea.example.com/hyperguild/infra.git","html_url":"https://gitea.example.com/hyperguild/infra"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"hyperguild"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"hyperguild","name":"infra","is_org":true}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"full_name":"hyperguild/infra"`)
|
||||
}
|
||||
|
||||
func TestRepoCreateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
59
internal/tools/repo_delete.go
Normal file
59
internal/tools/repo_delete.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoDelete struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoDelete(c *gitea.Client, a *allowlist.Allowlist) *RepoDelete {
|
||||
return &RepoDelete{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoDelete) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_delete",
|
||||
Description: "Permanently delete a repository. Requires confirm=<repo name> to prevent accidents.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"confirm":{"type":"string","description":"Must equal the repo name exactly to proceed."}
|
||||
},
|
||||
"required":["owner","name","confirm"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoDeleteArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoDeleteArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Confirm != args.Name {
|
||||
return nil, fmt.Errorf("repo_delete requires confirm=%q to match the repo name — got %q", args.Name, args.Confirm)
|
||||
}
|
||||
if err := t.c.DeleteRepo(ctx, args.Owner, args.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]string{"status": "deleted", "repo": args.Owner + "/" + args.Name})
|
||||
}
|
||||
52
internal/tools/repo_delete_test.go
Normal file
52
internal/tools/repo_delete_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoDeleteTool_WithCorrectConfirm(t *testing.T) {
|
||||
deleted := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
|
||||
deleted = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoDelete(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"infra"}`))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, deleted, "DELETE request must have been sent")
|
||||
assert.Contains(t, string(out), "deleted")
|
||||
}
|
||||
|
||||
func TestRepoDeleteTool_WrongConfirmBlocked(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","confirm":"wrong"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
func TestRepoDeleteTool_MissingConfirmBlocked(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
func TestRepoDeleteAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoDelete(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","confirm":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
117
internal/tools/repo_mirror_push.go
Normal file
117
internal/tools/repo_mirror_push.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoMirrorPush struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush {
|
||||
return &RepoMirrorPush{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_mirror_push",
|
||||
Description: "Manage push mirrors for a repository: add, list, or delete.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"action":{"type":"string","enum":["add","list","delete"]},
|
||||
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
|
||||
"remote_username":{"type":"string"},
|
||||
"remote_password":{"type":"string","description":"Never logged or returned."},
|
||||
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
|
||||
"sync_on_commit":{"type":"boolean"},
|
||||
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
|
||||
},
|
||||
"required":["owner","name","action"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoMirrorPushArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
RemoteUsername string `json:"remote_username"`
|
||||
RemotePassword string `json:"remote_password"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
MirrorName string `json:"mirror_name"`
|
||||
}
|
||||
|
||||
// safeMirror omits remote_password so it is never returned to the caller.
|
||||
type safeMirror struct {
|
||||
ID int `json:"id"`
|
||||
RemoteName string `json:"remote_name"`
|
||||
RemoteAddress string `json:"remote_address"`
|
||||
Interval string `json:"interval"`
|
||||
SyncOnCommit bool `json:"sync_on_commit"`
|
||||
}
|
||||
|
||||
func toSafeMirror(m *gitea.PushMirror) safeMirror {
|
||||
return safeMirror{
|
||||
ID: m.ID,
|
||||
RemoteName: m.RemoteName,
|
||||
RemoteAddress: m.RemoteAddress,
|
||||
Interval: m.Interval,
|
||||
SyncOnCommit: m.SyncOnCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoMirrorPushArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch args.Action {
|
||||
case "add":
|
||||
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Name, gitea.AddPushMirrorArgs{
|
||||
RemoteAddress: args.RemoteAddress,
|
||||
RemoteUsername: args.RemoteUsername,
|
||||
RemotePassword: args.RemotePassword,
|
||||
Interval: args.Interval,
|
||||
SyncOnCommit: args.SyncOnCommit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(toSafeMirror(m))
|
||||
case "list":
|
||||
mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safe := make([]safeMirror, len(mirrors))
|
||||
for i := range mirrors {
|
||||
safe[i] = toSafeMirror(&mirrors[i])
|
||||
}
|
||||
return textOK(safe)
|
||||
case "delete":
|
||||
if args.MirrorName == "" {
|
||||
return nil, fmt.Errorf("mirror_name is required for action=delete")
|
||||
}
|
||||
if err := t.c.DeletePushMirror(ctx, args.Owner, args.Name, args.MirrorName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action)
|
||||
}
|
||||
}
|
||||
80
internal/tools/repo_mirror_push_test.go
Normal file
80
internal/tools/repo_mirror_push_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoMirrorPushTool_Add(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{
|
||||
"owner":"mathias","name":"infra","action":"add",
|
||||
"remote_address":"https://github.com/mathias/infra.git",
|
||||
"remote_username":"mathias","remote_password":"secret",
|
||||
"interval":"8h0m0s","sync_on_commit":true
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
// password must never appear in output
|
||||
assert.NotContains(t, string(out), "secret")
|
||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_List(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"remote_name":"mirror-github","remote_address":"https://github.com/mathias/infra.git","interval":"8h0m0s","sync_on_commit":true}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"list"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"remote_name":"mirror-github"`)
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_Delete(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/push_mirrors/mirror-github", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete","mirror_name":"mirror-github"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), "deleted")
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_DeleteRequiresMirrorName(t *testing.T) {
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","action":"delete"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mirror_name")
|
||||
}
|
||||
|
||||
func TestRepoMirrorPushTool_AllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","action":"list"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
55
internal/tools/repo_topics_update.go
Normal file
55
internal/tools/repo_topics_update.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoTopicsUpdate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate {
|
||||
return &RepoTopicsUpdate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_topics_update",
|
||||
Description: "Replace the topic list for a repository.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."}
|
||||
},
|
||||
"required":["owner","name","topics"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoTopicsUpdateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
|
||||
func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoTopicsUpdateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(map[string]any{"status": "updated", "topics": args.Topics})
|
||||
}
|
||||
35
internal/tools/repo_topics_update_test.go
Normal file
35
internal/tools/repo_topics_update_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoTopicsUpdateTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPut, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), "updated")
|
||||
}
|
||||
|
||||
func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
56
internal/tools/repo_tree.go
Normal file
56
internal/tools/repo_tree.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoTree struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree {
|
||||
return &RepoTree{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoTree) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_tree",
|
||||
Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"ref":{"type":"string","description":"Branch, tag, or commit SHA."}
|
||||
},
|
||||
"required":["owner","name","ref"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoTreeArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoTreeArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(tree)
|
||||
}
|
||||
50
internal/tools/repo_tree_test.go
Normal file
50
internal/tools/repo_tree_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoTreeTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path)
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi","size":0}],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"sha":"abc"`)
|
||||
assert.Contains(t, string(out), `"path":"README.md"`)
|
||||
}
|
||||
|
||||
func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "1", r.URL.Query().Get("recursive"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRepoTreeAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
91
internal/tools/repo_update.go
Normal file
91
internal/tools/repo_update.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type RepoUpdate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
||||
return &RepoUpdate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_update",
|
||||
Description: "Update repository metadata (description, visibility, default branch, website, archived, template). " +
|
||||
"Only fields explicitly set in the call are patched. " +
|
||||
"WARNING: private=false exposes the repo publicly — verify intent before calling.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
|
||||
"website":{"type":"string","description":"Homepage URL"},
|
||||
"default_branch":{"type":"string","description":"Rename the default branch"},
|
||||
"archived":{"type":"boolean","description":"Mark repo as archived (read-only)."},
|
||||
"template":{"type":"boolean","description":"Toggle template-repo flag"},
|
||||
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoUpdateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Template *bool `json:"template,omitempty"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoUpdateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Making a repo public is a significant action — require explicit confirmation.
|
||||
if args.Private != nil && !*args.Private {
|
||||
if args.Confirm != args.Name {
|
||||
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if args.Description == nil && args.Private == nil && args.Website == nil &&
|
||||
args.DefaultBranch == nil && args.Archived == nil && args.Template == nil {
|
||||
return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
Website: args.Website,
|
||||
DefaultBranch: args.DefaultBranch,
|
||||
Archived: args.Archived,
|
||||
Template: args.Template,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textOK(r)
|
||||
}
|
||||
153
internal/tools/repo_update_test.go
Normal file
153
internal/tools/repo_update_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
|
||||
return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
}
|
||||
|
||||
// TestRepoUpdateArchive: happy path — set archived=true.
|
||||
func TestRepoUpdateArchive(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPatch, r.Method)
|
||||
require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path)
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"old-svc","archived":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wire payload only contains the field that was actually set.
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, true, sent["archived"])
|
||||
assert.NotContains(t, sent, "description")
|
||||
assert.NotContains(t, sent, "private")
|
||||
assert.NotContains(t, sent, "website")
|
||||
assert.NotContains(t, sent, "template")
|
||||
|
||||
var repo gitea.Repo
|
||||
require.NoError(t, json.Unmarshal(result, &repo))
|
||||
assert.Equal(t, "mathias/old-svc", repo.FullName)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMultipleFields: set description + template flag in one call.
|
||||
func TestRepoUpdateMultipleFields(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, "Go agent template", sent["description"])
|
||||
assert.Equal(t, true, sent["template"])
|
||||
assert.NotContains(t, sent, "archived")
|
||||
assert.NotContains(t, sent, "private")
|
||||
}
|
||||
|
||||
// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network.
|
||||
func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo"}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMakePublic: private=false requires confirm=<repo name> as a safety
|
||||
// gate (kept from main #21 during the v02-patch merge). With confirm matching, the
|
||||
// patch goes through.
|
||||
func TestRepoUpdateMakePublic(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"open-repo","private":false,"confirm":"open-repo"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, false, sent["private"])
|
||||
}
|
||||
|
||||
// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation.
|
||||
func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"open-repo","private":false}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "confirm")
|
||||
}
|
||||
|
||||
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
||||
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"evil","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream.
|
||||
func TestRepoUpdateUpstreamError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrUpstream)
|
||||
}
|
||||
87
internal/tools/workflow_run_list.go
Normal file
87
internal/tools/workflow_run_list.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type WorkflowRunList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewWorkflowRunList(c *gitea.Client, a *allowlist.Allowlist) *WorkflowRunList {
|
||||
return &WorkflowRunList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *WorkflowRunList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "workflow_run_list",
|
||||
Description: "List recent Gitea Actions workflow runs with optional filters (branch, head_sha, status, event, workflow).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"branch":{"type":"string"},
|
||||
"head_sha":{"type":"string"},
|
||||
"status":{"type":"string","enum":["queued","in_progress","completed","all"]},
|
||||
"event":{"type":"string","enum":["push","pull_request","schedule","workflow_dispatch","all"]},
|
||||
"workflow":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type workflowRunListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event"`
|
||||
Workflow string `json:"workflow"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args workflowRunListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 10)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
resp, err := t.c.ListWorkflowRuns(ctx, args.Owner, args.Name, gitea.ListWorkflowRunsArgs{
|
||||
Branch: args.Branch,
|
||||
HeadSHA: args.HeadSHA,
|
||||
Status: args.Status,
|
||||
Event: args.Event,
|
||||
Workflow: args.Workflow,
|
||||
Page: args.Page,
|
||||
Limit: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"runs": resp.WorkflowRuns,
|
||||
"total": resp.TotalCount,
|
||||
}
|
||||
if len(resp.WorkflowRuns) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
98
internal/tools/workflow_run_list_test.go
Normal file
98
internal/tools/workflow_run_list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowRunListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
notQuery []string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp"}`,
|
||||
wantQuery: map[string]string{"page": "1", "limit": "10"},
|
||||
respBody: `{"total_count":2,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"},{"id":822,"status":"completed","conclusion":"success","head_sha":"c4bd339"}]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"id":823`)
|
||||
assert.Contains(t, out, `"total":2`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "head_sha short filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","head_sha":"dc907fb"}`,
|
||||
wantQuery: map[string]string{"head_sha": "dc907fb"},
|
||||
respBody: `{"total_count":1,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"}]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"id":823`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"in_progress"}`,
|
||||
wantQuery: map[string]string{"status": "in_progress"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"runs":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status=all is no-op",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
||||
notQuery: []string{"status"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "branch filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","branch":"main"}`,
|
||||
wantQuery: map[string]string{"branch": "main"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
}
|
||||
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/gitea-mcp/actions/runs", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
for _, k := range tc.notQuery {
|
||||
assert.Equal(t, "", q.Get(k), "query param %q should be absent", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewWorkflowRunList(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 TestWorkflowRunListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewWorkflowRunList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user