Compare commits
16
Commits
3cccbfb8cb
..
v0.2.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184d5a95dd | ||
|
|
e2594f45f2 | ||
|
|
f99a8b38cb | ||
|
|
8a751741a4 | ||
|
|
668e8fa28d | ||
|
|
3c1ca7d3db | ||
|
|
8bea0d2f27 | ||
|
|
24c353383f | ||
|
|
be85baf410 | ||
|
|
658f4ba84f | ||
|
|
60212fc5d2 | ||
|
|
dc907fb7e0 | ||
|
|
c4bd3396c4 | ||
|
|
11f86f5d99 | ||
|
|
f7076c9ac8 | ||
|
|
e31fd3f023 |
+95
-100
@@ -36,6 +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.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **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
|
## Default stack
|
||||||
|
|
||||||
@@ -49,6 +61,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -58,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
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -100,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://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
|
## Client work rules
|
||||||
|
|
||||||
@@ -208,9 +270,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -248,98 +312,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|||||||
+22
-89
@@ -37,9 +37,11 @@
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -77,98 +79,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|||||||
+4
-1
@@ -16,7 +16,10 @@
|
|||||||
},
|
},
|
||||||
"infra": {
|
"infra": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://infra-mcp.d-ma.be/mcp"
|
"url": "https://infra-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-100
@@ -41,6 +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.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **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
|
## Default stack
|
||||||
|
|
||||||
@@ -54,6 +66,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -63,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
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -105,18 +121,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://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
|
## Client work rules
|
||||||
|
|
||||||
@@ -213,9 +275,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -253,100 +317,31 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+95
-100
@@ -39,6 +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.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **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
|
## Default stack
|
||||||
|
|
||||||
@@ -52,6 +64,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -61,9 +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
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -103,18 +119,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://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
|
## Client work rules
|
||||||
|
|
||||||
@@ -211,9 +273,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -251,98 +315,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
name: CD
|
name: CD
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea-mcp
|
IMAGE: gitea-mcp
|
||||||
@@ -43,7 +41,6 @@ jobs:
|
|||||||
name: Build & Import
|
name: Build & Import
|
||||||
needs: check
|
needs: check
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
outputs:
|
outputs:
|
||||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
echo "→ Running task check before push..."
|
||||||
|
task check
|
||||||
|
echo "✓ pre-push check passed"
|
||||||
@@ -36,6 +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.
|
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
completion without evidence (tests pass, command output, observed behavior).
|
||||||
|
5. **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
|
## Default stack
|
||||||
|
|
||||||
@@ -49,6 +61,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | — | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||||
|
|
||||||
@@ -58,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
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what*
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
|
one logical change per commit, CI is the quality gate
|
||||||
|
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
||||||
|
passing `task check` locally first
|
||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
@@ -100,18 +116,64 @@ See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
||||||
|
|
||||||
## Knowledge base
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
When available, agents can query the shared knowledge base:
|
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
||||||
|
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
||||||
|
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
||||||
|
reference material — query it actively, not just when explicitly told.**
|
||||||
|
|
||||||
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
### When to query (treat as a reflex)
|
||||||
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
|
||||||
|
|
||||||
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
- **Before** starting a non-trivial task — search for prior art with the symptom
|
||||||
name once hyperguild is deployed. Until then, agents that try to
|
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
||||||
reach the knowledge service on a host where it isn't running will
|
- **When debugging** — search for the error string, the stack frame, the affected
|
||||||
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
service. Past you may have already paid this tax.
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
- **Before adopting** a pattern, library, framework, or model name — check if it
|
||||||
|
was tried and rejected, or what the integration footguns are.
|
||||||
|
- **When making architectural decisions** — search for the domain + "ADR" or
|
||||||
|
"decision" to find prior reasoning before re-deriving it.
|
||||||
|
- **When a recommendation feels novel** — challenge yourself: "has this been
|
||||||
|
documented?" The brain often has it.
|
||||||
|
|
||||||
|
### When to write
|
||||||
|
|
||||||
|
After you discover something that **future-you would forget** and that **isn't
|
||||||
|
recoverable from the code, git log, or PR description alone**:
|
||||||
|
|
||||||
|
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
||||||
|
- Framework / library / model-name quirks that bit you and would bite anyone.
|
||||||
|
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
||||||
|
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
||||||
|
|
||||||
|
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
||||||
|
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
||||||
|
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||||
|
|
||||||
|
### How to access (per harness)
|
||||||
|
|
||||||
|
| Harness | Query | Write |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
|
| **Browser / human inspection** | `https://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
|
## Client work rules
|
||||||
|
|
||||||
@@ -208,9 +270,11 @@ Key skills:
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -248,98 +312,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|||||||
@@ -37,9 +37,11 @@
|
|||||||
|
|
||||||
### Git
|
### Git
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
|
||||||
- **Branch protection:** always work on a feature branch, open a PR, never push directly to main
|
- No feature branches, no PRs for solo/agent work.
|
||||||
|
- Exception: if a parallel agent session is active on this repo, use a short-lived
|
||||||
|
`agent/<description>` branch and merge within the same session.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
||||||
@@ -77,98 +79,29 @@ When acting as a coding agent on this project:
|
|||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
3. If unsure about a convention, check `DECISIONS.md` or ask
|
||||||
4. Never modify files outside the project root without explicit permission
|
4. Never modify files outside the project root without explicit permission
|
||||||
5. When adding a dependency, explain why in the commit message
|
5. When adding a dependency, explain why in the commit message
|
||||||
6. Always work on a feature branch and open a PR — never push directly to main
|
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
|
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
## Current sprint — gitea-mcp v0.2 patch (2026-05-14)
|
## Current state — v0.2.5 (2026-05-17)
|
||||||
|
|
||||||
### Context
|
All v0.2 work is complete and deployed. No active sprint.
|
||||||
The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete,
|
|
||||||
repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create,
|
|
||||||
create_project_from_template) was implemented and pushed directly to main.
|
|
||||||
|
|
||||||
This sprint fixes three remaining gaps found during code review on 2026-05-14.
|
### What shipped
|
||||||
These are blockers for `hyperguild new-project`.
|
|
||||||
|
|
||||||
### Issues to fix (all three in one PR: `fix/v02-patch`)
|
| 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 |
|
||||||
|
|
||||||
#### #12 — repo_update: add `archived` and `template` fields
|
Current main: `e31fd3f`. CI green. Deployed via Flux.
|
||||||
**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct
|
|
||||||
**File:** `internal/tools/repo_update.go` → input schema + args struct
|
|
||||||
|
|
||||||
Add to `UpdateRepoArgs`:
|
### Next up
|
||||||
```go
|
|
||||||
Archived *bool
|
|
||||||
Template *bool
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to tool input schema:
|
1. **`hyperguild new-project` v1** — primary next target.
|
||||||
```json
|
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
|
||||||
"archived": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Mark repo as archived (read-only). Requires confirm=<repo name>."
|
|
||||||
},
|
|
||||||
"template": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Toggle template repo flag."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add confirm-guard for `archived=true` (same pattern as `private=false`):
|
2. **Issue #19** — end-to-end mirror flow verification.
|
||||||
```go
|
`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.
|
||||||
if args.Archived != nil && *args.Archived {
|
|
||||||
if args.Confirm != args.Name {
|
|
||||||
return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New test cases to add in `repo_update_test.go`:
|
|
||||||
- `TestRepoUpdateTool_Archive` — happy path with confirm
|
|
||||||
- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error
|
|
||||||
- `TestRepoUpdateTool_SetTemplate` — no confirm needed
|
|
||||||
|
|
||||||
#### #24 — create_project_from_template: make template selectable
|
|
||||||
**File:** `internal/tools/create_project_from_template.go`
|
|
||||||
|
|
||||||
Add optional `template_name` param to input schema:
|
|
||||||
```json
|
|
||||||
"template_name": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["template-go-web", "template-go-agent"],
|
|
||||||
"description": "Template repo to generate from. Defaults to template-go-web.",
|
|
||||||
"default": "template-go-web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool should use `args.TemplateName` if set, fall back to the hardcoded default.
|
|
||||||
Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call —
|
|
||||||
the tool resolves it internally.
|
|
||||||
|
|
||||||
New test case: `TestCreateProjectFromTemplate_AgentTemplate`
|
|
||||||
|
|
||||||
#### #25 — pr_files_diff: fix same diff returned for all files
|
|
||||||
**File:** `internal/tools/pr_files_diff.go`
|
|
||||||
|
|
||||||
There is a loop bug where all file entries in the response contain the same diff
|
|
||||||
(the first file's diff is reused for every subsequent file). Find the loop and
|
|
||||||
ensure each iteration reads and assigns the correct diff for its own file.
|
|
||||||
|
|
||||||
Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has
|
|
||||||
a distinct diff.
|
|
||||||
|
|
||||||
### Definition of done
|
|
||||||
|
|
||||||
- [ ] `task check` passes
|
|
||||||
- [ ] `repo_update` accepts `archived` and `template` params
|
|
||||||
- [ ] `archived=true` requires `confirm=<repo name>`
|
|
||||||
- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web`
|
|
||||||
- [ ] `pr_files_diff` returns distinct diff per file
|
|
||||||
- [ ] All new test cases pass
|
|
||||||
- [ ] PR `fix/v02-patch` merged to main via PR (not direct push)
|
|
||||||
|
|
||||||
### After this sprint
|
|
||||||
|
|
||||||
Next: `hyperguild new-project` v1 implementation.
|
|
||||||
See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec.
|
|
||||||
Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working.
|
|
||||||
|
|||||||
+11
@@ -1,5 +1,16 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going
|
||||||
|
# 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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -2,3 +2,14 @@
|
|||||||
|
|
||||||
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
Streamable HTTP MCP service exposing Gitea repo operations to Claude apps.
|
||||||
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
See `~/dev/AI/infra/docs/superpowers/specs/2026-05-04-gitea-mcp-gitops-workflow-design.md`.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task setup:hooks # installs .githooks/pre-push — runs task check before every push
|
||||||
|
task check # context sync + lint + test + vet
|
||||||
|
task build # produces bin/gitea-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
This repo uses Trunk-Based Development. Commit directly to `main`. The pre-push
|
||||||
|
hook enforces the quality gate locally; CI re-runs `task check` on every push.
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- bash scripts/context-sync.sh
|
- bash scripts/context-sync.sh
|
||||||
|
|
||||||
|
setup:hooks:
|
||||||
|
desc: Install git hooks (.githooks/pre-push)
|
||||||
|
cmds:
|
||||||
|
- git config core.hooksPath .githooks
|
||||||
|
- chmod +x .githooks/pre-push
|
||||||
|
- echo "✓ git hooks installed (pre-push runs task check)"
|
||||||
|
|
||||||
context:sync:claude:
|
context:sync:claude:
|
||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
context:sync:agents:
|
context:sync:agents:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type healthStatus struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
JWT jwtStatus `json:"jwt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtStatus surfaces the runtime state of the Dex JWT validator so ops
|
||||||
|
// can distinguish "Dex unreachable at startup" from "JWT auth not
|
||||||
|
// configured" — both previously degraded silently to static-token-only
|
||||||
|
// (refs hyperguild/gitea-mcp#6).
|
||||||
|
type jwtStatus struct {
|
||||||
|
// Status is one of: "disabled" (DEX_ISSUER_URL not set),
|
||||||
|
// "enabled" (validator initialized), "degraded" (configured but
|
||||||
|
// init failed; only static-token auth currently accepted).
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
||||||
|
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
||||||
|
if initErr != nil {
|
||||||
|
status.JWT.LastError = initErr.Error()
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(status)
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtStatusFor(dexConfigured, validatorReady bool) string {
|
||||||
|
switch {
|
||||||
|
case !dexConfigured:
|
||||||
|
return "disabled"
|
||||||
|
case validatorReady:
|
||||||
|
return "enabled"
|
||||||
|
default:
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthzHandler(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dexConfigured bool
|
||||||
|
validatorReady bool
|
||||||
|
initErr error
|
||||||
|
wantStatus string
|
||||||
|
wantLastError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled when DEX_ISSUER_URL unset",
|
||||||
|
wantStatus: "disabled",
|
||||||
|
wantLastError: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled when validator initialized",
|
||||||
|
dexConfigured: true,
|
||||||
|
validatorReady: true,
|
||||||
|
wantStatus: "enabled",
|
||||||
|
wantLastError: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "degraded when Dex configured but init failed",
|
||||||
|
dexConfigured: true,
|
||||||
|
initErr: errors.New("fetch oidc discovery: dial tcp: connection refused"),
|
||||||
|
wantStatus: "degraded",
|
||||||
|
wantLastError: "fetch oidc discovery: dial tcp: connection refused",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var got healthStatus
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
assert.True(t, got.OK)
|
||||||
|
assert.Equal(t, tc.wantStatus, got.JWT.Status)
|
||||||
|
assert.Equal(t, tc.wantLastError, got.JWT.LastError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-56
@@ -2,10 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"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/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
@@ -27,78 +29,41 @@ func main() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
jwtValidator, jwtInitErr := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||||
if err != nil {
|
if jwtInitErr != nil {
|
||||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
|
||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
|
||||||
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
|
||||||
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
|
||||||
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
Sessions: mcp.NewSessionStore(),
|
Sessions: mcp.NewSessionStore(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
||||||
|
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
||||||
|
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
||||||
|
var resourceMetadataURL string
|
||||||
|
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
||||||
|
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
auth.CallerMiddleware(mcpSrv),
|
auth.CallerMiddleware(mcpSrv),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
w.WriteHeader(http.StatusOK)
|
if cfg.DexIssuerURL != "" {
|
||||||
_, _ = w.Write([]byte("ok"))
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
})
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||||
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
|
}
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
payload := map[string]any{
|
|
||||||
"resource": cfg.MCPResourceURL,
|
|
||||||
"authorization_servers": []string{},
|
|
||||||
}
|
|
||||||
if cfg.DexIssuerURL != "" {
|
|
||||||
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
|||||||
@@ -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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,10 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||||
@@ -40,7 +42,21 @@ func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string,
|
|||||||
return repo.DefaultBranch, nil
|
return repo.DefaultBranch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasEmptySegment reports whether the path portion (before any query string)
|
||||||
|
// contains an empty segment ("//"), which means an owner or repo path
|
||||||
|
// parameter was empty. Forwarding it upstream yields gitea's opaque
|
||||||
|
// /api/swagger 404 (#36), so callers reject it locally instead.
|
||||||
|
func hasEmptySegment(path string) bool {
|
||||||
|
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||||
|
path = path[:i]
|
||||||
|
}
|
||||||
|
return strings.Contains(path, "//")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
|
||||||
|
if hasEmptySegment(path) {
|
||||||
|
return nil, 0, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||||
|
}
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
reader = bytes.NewReader(body)
|
reader = bytes.NewReader(body)
|
||||||
@@ -107,6 +123,9 @@ type rawResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
|
||||||
|
if hasEmptySegment(path) {
|
||||||
|
return nil, fmt.Errorf("%w: upstream path %q has an empty owner or repo segment", ErrValidation, path)
|
||||||
|
}
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
reader = bytes.NewReader(body)
|
reader = bytes.NewReader(body)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #36 second line of defence: when owner or repo is empty the path contains an
|
||||||
|
// empty segment ("//"). Rather than forward it upstream — where gitea answers
|
||||||
|
// with its opaque /api/swagger 404 — the client must reject it locally with a
|
||||||
|
// validation error and never touch the network.
|
||||||
|
func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
"/api/v1/repos/mathias//issues", // empty repo
|
||||||
|
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||||
|
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
_, _, err := c.GetJSON(context.Background(), p)
|
||||||
|
require.Error(t, err, "path %q should be rejected", p)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "guard must short-circuit before any HTTP call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well-formed path with a query string must still pass the guard.
|
||||||
|
func TestWellFormedPathPasses(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, status, err := c.GetJSON(context.Background(), "/api/v1/repos/mathias/gitea-mcp/issues?state=open")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, status)
|
||||||
|
}
|
||||||
+133
-13
@@ -4,19 +4,21 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Labels []Label `json:"labels"`
|
Labels []Label `json:"labels"`
|
||||||
Assignees []User `json:"assignees"`
|
Assignees []User `json:"assignees"`
|
||||||
Comments int `json:"comments"`
|
Comments int `json:"comments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
@@ -72,10 +74,128 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
|
|||||||
return &iss, nil
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListIssuesArgs captures the optional query params for ListIssues.
|
||||||
|
type ListIssuesArgs struct {
|
||||||
|
State string // "open" | "closed" | "all"
|
||||||
|
Labels string // comma-separated label names
|
||||||
|
Since string // ISO 8601
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssues fetches issues for a repo. Pulls are excluded server-side
|
||||||
|
// (type=issues) so they don't leak through the same endpoint.
|
||||||
|
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("type", "issues")
|
||||||
|
if args.State != "" {
|
||||||
|
q.Set("state", args.State)
|
||||||
|
}
|
||||||
|
if args.Labels != "" {
|
||||||
|
q.Set("labels", args.Labels)
|
||||||
|
}
|
||||||
|
if args.Since != "" {
|
||||||
|
q.Set("since", args.Since)
|
||||||
|
}
|
||||||
|
if args.Page > 0 {
|
||||||
|
q.Set("page", strconv.Itoa(args.Page))
|
||||||
|
}
|
||||||
|
if args.Limit > 0 {
|
||||||
|
q.Set("limit", strconv.Itoa(args.Limit))
|
||||||
|
}
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var issues []Issue
|
||||||
|
if err := json.Unmarshal(body, &issues); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIssueState flips an issue between "open" and "closed" via PATCH.
|
||||||
|
// Gitea uses the same endpoint for both transitions.
|
||||||
|
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(map[string]string{"state": state})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueArgs uses pointers so omitempty distinguishes "not set" (nil,
|
||||||
|
// left untouched) from an explicit empty string (clears the field). Maps to
|
||||||
|
// Gitea's PATCH /repos/{owner}/{repo}/issues/{index}.
|
||||||
|
type EditIssueArgs struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssue patches an issue's title and/or body. Only fields set in args are
|
||||||
|
// sent, so omitted fields are left as-is server-side. Body is sent verbatim —
|
||||||
|
// no identity footer — so repeated edits are idempotent.
|
||||||
|
func (c *Client) EditIssue(ctx context.Context, owner, repo string, number int, args EditIssueArgs) (*Issue, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PatchJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var iss Issue
|
||||||
|
if err := json.Unmarshal(body, &iss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &iss, nil
|
||||||
|
}
|
||||||
|
|
||||||
type IssueComment struct {
|
type IssueComment struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
User User `json:"user,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueComments fetches all comments on an issue or pull request.
|
||||||
|
// Per Gitea, /issues/{index}/comments serves both since PRs share index space with issues.
|
||||||
|
func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int) ([]IssueComment, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
|
||||||
|
body, status, err := c.GetJSON(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var comments []IssueComment
|
||||||
|
if err := json.Unmarshal(body, &comments); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
|
||||||
|
|||||||
@@ -76,6 +76,96 @@ func TestGetIssue_NotFound(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditIssue(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"new title","body":"new body","state":"open","html_url":"http://example.com/issues/42"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title, body := "new title", "new body"
|
||||||
|
iss, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title, Body: &body})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "new title", payload["title"])
|
||||||
|
assert.Equal(t, "new body", payload["body"])
|
||||||
|
|
||||||
|
assert.Equal(t, 42, iss.Number)
|
||||||
|
assert.Equal(t, "new title", iss.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssue must send only the fields explicitly provided — an omitted field
|
||||||
|
// (nil pointer) is left untouched server-side.
|
||||||
|
func TestEditIssue_PartialPatchOmitsUnsetFields(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"title":"only title","state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title := "only title"
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Title: &title})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "only title", payload["title"])
|
||||||
|
_, hasBody := payload["body"]
|
||||||
|
assert.False(t, hasBody, "body must be omitted when not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit empty-string body clears the field — pointer-to-"" is sent, not omitted.
|
||||||
|
func TestEditIssue_EmptyBodyIsSent(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":42,"body":"","state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
body := ""
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 42, gitea.EditIssueArgs{Body: &body})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
val, hasBody := payload["body"]
|
||||||
|
assert.True(t, hasBody, "explicit empty body must be sent so it can clear the field")
|
||||||
|
assert.Equal(t, "", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditIssue_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
title := "x"
|
||||||
|
_, err := c.EditIssue(context.Background(), "o", "r", 999, gitea.EditIssueArgs{Title: &title})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateIssueComment(t *testing.T) {
|
func TestCreateIssueComment(t *testing.T) {
|
||||||
var captured []byte
|
var captured []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -101,3 +191,51 @@ func TestCreateIssueComment(t *testing.T) {
|
|||||||
assert.Equal(t, "hello", comment.Body)
|
assert.Equal(t, "hello", comment.Body)
|
||||||
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"body":"first","html_url":"http://example.com/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||||
|
{"id":2,"body":"second","html_url":"http://example.com/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, comments, 2)
|
||||||
|
assert.Equal(t, int64(1), comments[0].ID)
|
||||||
|
assert.Equal(t, "first", comments[0].Body)
|
||||||
|
assert.Equal(t, "alice", comments[0].User.Login)
|
||||||
|
assert.Equal(t, "bob", comments[1].User.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
comments, err := c.ListIssueComments(context.Background(), "o", "r", 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIssueComments_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
_, err := c.ListIssueComments(context.Background(), "o", "r", 999)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -54,11 +55,21 @@ func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow str
|
|||||||
|
|
||||||
// WorkflowRun represents a Gitea Actions run.
|
// WorkflowRun represents a Gitea Actions run.
|
||||||
type WorkflowRun struct {
|
type WorkflowRun struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Status string `json:"status"` // queued | in_progress | completed
|
DisplayTitle string `json:"display_title,omitempty"`
|
||||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
Status string `json:"status"` // queued | in_progress | completed
|
||||||
StartedAt string `json:"started_at"`
|
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||||
HTMLURL string `json:"html_url"`
|
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.
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #36: every caller (claude.ai connector, gitea's own convention) sends the
|
||||||
|
// repo identifier as `repo` and issue/PR index as `index`, but the tools
|
||||||
|
// declare them as `name` and `number`. Unmatched fields zero-valued the path
|
||||||
|
// segment and produced gitea's misleading /api/swagger 404. parseArgs now
|
||||||
|
// aliases repo->name and index->number so the idiomatic call works.
|
||||||
|
func TestRepoAndIndexAliasesResolve(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{"repo+index aliases", `{"owner":"mathias","repo":"infra","index":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"canonical name+number", `{"owner":"mathias","name":"infra","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
{"explicit name wins over repo", `{"owner":"mathias","name":"infra","repo":"ignored","number":7}`, "/api/v1/repos/mathias/infra/issues/7"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":7,"title":"t"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(tc.args))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantPath, gotPath)
|
||||||
|
assert.Contains(t, string(out), `"number":7`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 IssueEdit struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueEdit(c *gitea.Client, a *allowlist.Allowlist) *IssueEdit {
|
||||||
|
return &IssueEdit{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueEdit) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_edit",
|
||||||
|
Description: "Edit an existing issue's title and/or body. Only fields explicitly set are patched; " +
|
||||||
|
"omitted fields are left untouched. Body is replaced verbatim (no identity footer) so edits are idempotent. " +
|
||||||
|
"WARNING: body is a full replacement — to amend rather than clobber, read-modify-write " +
|
||||||
|
"(fetch with issue_get, edit the text, send it back).",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1},
|
||||||
|
"title":{"type":"string","description":"New title. Omit to leave unchanged."},
|
||||||
|
"body":{"type":"string","description":"New body, full replacement. Omit to leave unchanged."}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueEditArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Body *string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueEditArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args.Number < 1 {
|
||||||
|
return nil, fmt.Errorf("number is required: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
if args.Title == nil && args.Body == nil {
|
||||||
|
return nil, fmt.Errorf("at least one of title or body must be set: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss, err := t.c.EditIssue(ctx, args.Owner, args.Name, args.Number, gitea.EditIssueArgs{
|
||||||
|
Title: args.Title,
|
||||||
|
Body: args.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(map[string]any{
|
||||||
|
"number": iss.Number,
|
||||||
|
"title": iss.Title,
|
||||||
|
"html_url": iss.HTMLURL,
|
||||||
|
"state": iss.State,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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 TestIssueEditTool(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"title":"new","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"title":"new","body":"updated body"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "new", payload["title"])
|
||||||
|
assert.Equal(t, "updated body", payload["body"])
|
||||||
|
|
||||||
|
assert.Contains(t, string(out), `"number":26`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body must be sent verbatim — no identity footer appended (keeps edits idempotent).
|
||||||
|
func TestIssueEditTool_BodyVerbatim(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
captured, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":26,"state":"open"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26,"body":"exact text"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(captured, &payload))
|
||||||
|
assert.Equal(t, "exact text", payload["body"], "body must be unchanged — no footer")
|
||||||
|
_, hasTitle := payload["title"]
|
||||||
|
assert.False(t, hasTitle, "title must be omitted when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditTool_RequiresAField(t *testing.T) {
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
title := "x"
|
||||||
|
body, _ := json.Marshal(map[string]any{"owner": "mathias", "name": "infra", "number": 999, "title": title})
|
||||||
|
_, err := tool.Call(context.Background(), body)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueEditAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueEdit(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1,"title":"y"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -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 IssueListComments struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssueListComments(c *gitea.Client, a *allowlist.Allowlist) *IssueListComments {
|
||||||
|
return &IssueListComments{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueListComments) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "issue_list_comments",
|
||||||
|
Description: "List all comments on an issue or pull request. Returns id, body, author, html_url, and timestamps for each comment.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"number":{"type":"integer","minimum":1}
|
||||||
|
},
|
||||||
|
"required":["owner","name","number"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueListCommentsArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args issueListCommentsArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Name, args.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(comments)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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 TestIssueListCommentsTool(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/42/comments", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id":1,"body":"first","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-1","user":{"login":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||||
|
{"id":2,"body":"second","html_url":"http://gitea.example.com/mathias/infra/issues/42#comment-2","user":{"login":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `"id":1`)
|
||||||
|
assert.Contains(t, string(out), `"body":"first"`)
|
||||||
|
assert.Contains(t, string(out), `"login":"alice"`)
|
||||||
|
assert.Contains(t, string(out), `"login":"bob"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsTool_Empty(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":42}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(out), `[]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsTool_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueListCommentsAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewIssueListComments(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAll registers every gitea-mcp tool on reg. main.go and the
|
||||||
|
// dispatch round-trip test share this single list so a newly added tool
|
||||||
|
// cannot be wired in one place but missing from the other.
|
||||||
|
//
|
||||||
|
// giteaBaseURL is needed by workflow_run_trigger; tmplOwner/tmplRepo seed
|
||||||
|
// create_project_from_template's default source template.
|
||||||
|
func RegisterAll(
|
||||||
|
reg *registry.Registry,
|
||||||
|
c *gitea.Client,
|
||||||
|
a *allowlist.Allowlist,
|
||||||
|
giteaBaseURL, tmplOwner, tmplRepo string,
|
||||||
|
) {
|
||||||
|
reg.Register(NewRepoList(c, a))
|
||||||
|
reg.Register(NewRepoGet(c, a))
|
||||||
|
reg.Register(NewRepoSearch(c, a))
|
||||||
|
reg.Register(NewRepoStatus(c, a))
|
||||||
|
reg.Register(NewFileRead(c, a))
|
||||||
|
reg.Register(NewFileWriteBranch(c, a))
|
||||||
|
reg.Register(NewFileDelete(c, a))
|
||||||
|
reg.Register(NewDirList(c, a))
|
||||||
|
reg.Register(NewBranchList(c, a))
|
||||||
|
reg.Register(NewBranchDelete(c, a))
|
||||||
|
reg.Register(NewBranchProtectionGet(c, a))
|
||||||
|
reg.Register(NewPRCreate(c, a))
|
||||||
|
reg.Register(NewPRGet(c, a))
|
||||||
|
reg.Register(NewPRList(c, a))
|
||||||
|
reg.Register(NewPRMerge(c, a))
|
||||||
|
reg.Register(NewPRComment(c, a))
|
||||||
|
reg.Register(NewPRFilesDiff(c, a))
|
||||||
|
reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL))
|
||||||
|
reg.Register(NewWorkflowRunStatus(c, a))
|
||||||
|
reg.Register(NewCodeSearch(c, a))
|
||||||
|
reg.Register(NewIssueCreate(c, a))
|
||||||
|
reg.Register(NewIssueEdit(c, a))
|
||||||
|
reg.Register(NewIssueComment(c, a))
|
||||||
|
reg.Register(NewCreateProjectFromTemplate(c, a, tmplOwner, tmplRepo))
|
||||||
|
reg.Register(NewTagCreate(c, a))
|
||||||
|
reg.Register(NewRepoCreate(c, a))
|
||||||
|
reg.Register(NewRepoUpdate(c, a))
|
||||||
|
reg.Register(NewRepoMirrorPush(c, a))
|
||||||
|
reg.Register(NewRepoTree(c, a))
|
||||||
|
reg.Register(NewRepoTopicsUpdate(c, a))
|
||||||
|
reg.Register(NewIssueGet(c, a))
|
||||||
|
reg.Register(NewIssueList(c, a))
|
||||||
|
reg.Register(NewIssueListComments(c, a))
|
||||||
|
reg.Register(NewIssueClose(c, a))
|
||||||
|
reg.Register(NewIssueReopen(c, a))
|
||||||
|
reg.Register(NewWorkflowRunList(c, a))
|
||||||
|
reg.Register(NewReleaseCreate(c, a))
|
||||||
|
reg.Register(NewRepoDelete(c, a))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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/registry"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildRegistry wires the full tool set exactly as main.go does, against an
|
||||||
|
// unroutable gitea base URL so any handler that reaches the network fails fast
|
||||||
|
// (connection refused) rather than hanging.
|
||||||
|
func buildRegistry() *registry.Registry {
|
||||||
|
reg := registry.New()
|
||||||
|
c := gitea.NewClient("http://127.0.0.1:1", "")
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
tools.RegisterAll(reg, c, a, "http://127.0.0.1:1", "mathias", "template-go-web")
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every registered tool must be dispatchable and advertise a parseable input
|
||||||
|
// schema. This is the regression guard for #36's whole class: a tool that is
|
||||||
|
// wired up but unroutable (or ships a malformed schema) fails CI here instead
|
||||||
|
// of 404ing a live caller.
|
||||||
|
func TestEveryRegisteredToolIsDispatchable(t *testing.T) {
|
||||||
|
reg := buildRegistry()
|
||||||
|
descs := reg.Tools()
|
||||||
|
require.NotEmpty(t, descs)
|
||||||
|
|
||||||
|
for _, d := range descs {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
require.NotEmpty(t, d.Name, "tool has empty name")
|
||||||
|
assert.True(t, json.Valid(d.InputSchema),
|
||||||
|
"tool %q ships invalid JSON input schema", d.Name)
|
||||||
|
|
||||||
|
// Dispatch with empty args. We do not care whether the call
|
||||||
|
// succeeds (most fail allowlist/validation/network) — only that
|
||||||
|
// the name resolves to a handler. ErrToolNotFound here means the
|
||||||
|
// tool advertised a name the dispatcher cannot route.
|
||||||
|
_, err := reg.Dispatch(context.Background(), d.Name, json.RawMessage(`{}`))
|
||||||
|
assert.False(t, errors.Is(err, registry.ErrToolNotFound),
|
||||||
|
"registered tool %q does not dispatch", d.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock the tool count so an accidental drop of a registration in RegisterAll
|
||||||
|
// (the single source main.go and this test share) fails loudly.
|
||||||
|
func TestRegisteredToolCount(t *testing.T) {
|
||||||
|
assert.Len(t, buildRegistry().Tools(), 38)
|
||||||
|
}
|
||||||
+49
-1
@@ -2,6 +2,7 @@ package tools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
)
|
)
|
||||||
@@ -17,7 +18,54 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return json.Unmarshal([]byte("{}"), dst)
|
return json.Unmarshal([]byte("{}"), dst)
|
||||||
}
|
}
|
||||||
return json.Unmarshal(raw, dst)
|
return json.Unmarshal(normalizeAliases(raw), dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeAliases maps the near-universal gitea/GitHub argument names onto the
|
||||||
|
// idiosyncratic ones these tools declare: `repo` -> `name`, `index` -> `number`.
|
||||||
|
// Every MCP caller (the claude.ai connector, LLMs primed on gitea's own API)
|
||||||
|
// reaches for `repo`/`index`; without this the unmatched fields zero-valued the
|
||||||
|
// upstream path segment and produced gitea's misleading /api/swagger 404 (#36).
|
||||||
|
// The alias key is left intact so a tool whose real field IS `index`
|
||||||
|
// (e.g. pr_merge) is unaffected.
|
||||||
|
func normalizeAliases(raw json.RawMessage) json.RawMessage {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return raw // not a JSON object — leave untouched
|
||||||
|
}
|
||||||
|
changed := aliasInto(m, "name", "repo")
|
||||||
|
changed = aliasInto(m, "number", "index") || changed
|
||||||
|
if !changed {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if patched, err := json.Marshal(m); err == nil {
|
||||||
|
return patched
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// aliasInto copies m[alias] into m[canonical] when canonical is absent or an
|
||||||
|
// empty/zero JSON value and alias carries a usable one. An explicit canonical
|
||||||
|
// value always wins. Returns true if it wrote canonical.
|
||||||
|
func aliasInto(m map[string]json.RawMessage, canonical, alias string) bool {
|
||||||
|
av, ok := m[alias]
|
||||||
|
if !ok || isEmptyJSON(av) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if cv, ok := m[canonical]; ok && !isEmptyJSON(cv) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m[canonical] = av
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyJSON(v json.RawMessage) bool {
|
||||||
|
switch strings.TrimSpace(string(v)) {
|
||||||
|
case "", `""`, "null", "0":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
|
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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