Compare commits

...
15 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 184d5a95dd fix(tools,gitea): alias repo/index args and reject empty path segments
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 23s
Per-repo MCP tools 404'd while owner-level tools worked (#36). Root cause was
a parameter-name contract mismatch, not a routing fault: every per-repo tool
declares the repo identifier as 'name' (and issue/PR index as 'number'), but
every caller — the claude.ai connector, LLMs primed on gitea's own API — sends
'repo' and 'index'. The unmatched fields zero-valued the upstream path segment,
producing '/api/v1/repos/{owner}//...', which gitea answers with its generic
api-404 whose body points at /api/swagger. That swagger pointer is gitea boiler-
plate, not a misroute — the MCP dispatch was correct all along.

Two layers of defence:
- parseArgs aliases repo->name and index->number (explicit canonical wins;
  alias key left intact so pr_merge's real 'index' field is unaffected). Kills
  the recurrence by accepting the idiomatic argument names.
- the gitea client rejects any path with an empty segment before the HTTP call,
  returning ErrValidation instead of forwarding a malformed path and surfacing
  gitea's opaque swagger-404. Kills the silent-misleading-error class.

Closes #36

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:16:33 +02:00
mathiasandClaude Opus 4.8 e2594f45f2 refactor(tools): extract RegisterAll shared tool registration
main.go and tests now share one registration list so a tool wired in one
place cannot silently go missing from the other. Adds a dispatch round-trip
test asserting every registered tool resolves and ships a parseable schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:52:29 +02:00
mathias f99a8b38cb fix(ci): quote "on" key so Gitea parses workflow triggers
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped
Bare on: parses as YAML boolean true (Norway problem); Gitea then ignores the triggers and silently skips jobs. Quoting forces the string key.
2026-06-03 08:38:46 +02:00
mathias 8a751741a4 feat: add issue_edit tool (#34)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped
Adds issue_edit, mapping to Gitea's PATCH /repos/{owner}/{repo}/issues/{index},
so an existing issue's title and/or body can be edited through the MCP surface.
Previously the only post-create mutation was issue_comment, which buries
backlinks in the thread instead of the canonical body.

Partial patch via pointer fields (omitempty): omitted fields are left
untouched, an explicit empty string clears a field. Body is sent verbatim —
no identity footer — so repeated edits are idempotent, matching the acceptance
criteria. Registered alongside the other issue tools for tool_search discovery.

Closes #34
2026-06-02 16:11:33 +02:00
mathiasandClaude Opus 4.7 668e8fa28d feat: /healthz reports JWT validator status (refs #6)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 20s
CD / Deploy via GitOps (push) Successful in 4s
/healthz now returns JSON with three-state JWT status: disabled
(DEX_ISSUER_URL unset), enabled (validator initialized), or
degraded (configured but init failed — only static-token auth
currently accepted). last_error surfaces the init failure so ops
can correlate with Dex outage windows.

Partial fix for #6. The cited internal/auth/jwt.go moved out
of this repo in 658f4ba (mcp-chassis migration); per-attempt
logging and 503 + WWW-Authenticate temporarily_unavailable
require chassis-side changes and a coordinated v0.1.1 bump
across all MCP consumers — tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:55:09 +02:00
mathiasandClaude Opus 4.7 3c1ca7d3db feat: add issue_list_comments tool (closes #32)
CD / Lint / Test / Vet (push) Successful in 17s
CD / Build & Import (push) Successful in 19s
CD / Deploy via GitOps (push) Successful in 4s
Lists all comments on an issue or PR via GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments.
Read-only, allowlist-gated. Extended IssueComment struct with user/timestamps populated by the list endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:01:34 +02:00
mathiasandClaude Opus 4.7 8bea0d2f27 chore: remove stray cd.yml.notes file from CI retrigger commit
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 19s
CD / Deploy via GitOps (push) Successful in 4s
The file was an accident in commit 24c3533 — meant as a tmp marker,
should have been removed before commit. Harmless but trash. Removing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:26:35 +02:00
mathiasandClaude Opus 4.7 24c353383f ci: retrigger build after chassis repo made public
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 5s
mcp-chassis was created private on 2026-05-22 then ported here in
commit 658f4ba, which caused CI Build to fail when go mod download
hit the chassis URL and got prompted for credentials. The chassis is
now public (Gitea repo flipped via API). No code change needed; this
empty commit retriggers the build pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:17:54 +02:00
mathiasandClaude Opus 4.7 be85baf410 fix(ci): allow Dockerfile build to fetch internal gitea modules
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
mcp-chassis (added in commit 658f4ba) is hosted at gitea.d-ma.be, and
Gitea returns http:// in its go-import meta tag. Default go module
resolution goes through proxy.golang.org (which can't reach internal
hosts) and falls back to direct git, which gets the http:// URL and
refuses it.

Fix:
- GOPRIVATE=gitea.d-ma.be — skip proxy.golang.org
- GOPROXY=direct — direct git, no proxy attempt
- GOSUMDB=off — bypass sumdb (also doesn't know internal modules)
- git config insteadOf rewrites http:// → https:// for gitea.d-ma.be

Without this, gitea-mcp CI Build & Import failed on the chassis port
(sha=658f4ba). Re-running CI should now succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:12:33 +02:00
mathiasandClaude Opus 4.7 658f4ba84f feat(auth): migrate to gitea.d-ma.be/mathias/mcp-chassis v0.1.0
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Failing after 2s
CD / Deploy via GitOps (push) Has been skipped
First real port of the MCP chassis library — abort-criterion check for
spike S3 of the 2026-05 homelab architecture review.

Changes:
- Drop internal/auth/jwt.go (~79 LOC) — chassis provides JWTValidator
  with identical signature.
- Drop internal/auth/bearer.go (~42 LOC) — chassis BearerMiddleware
  has the same static-or-JWT semantics plus an optional WWW-Authenticate
  resource_metadata challenge (consumed via new resourceMetadataURL arg).
- Drop internal/auth/bearer_test.go — same scenarios are covered in
  the chassis bearer_test.go now.
- main.go: import chassis as `chassisauth`, build resourceMetadataURL
  only when both DexIssuerURL + MCPResourceURL are set, replace the
  inline /.well-known/oauth-protected-resource handler with the chassis
  ProtectedResourceHandler.

internal/auth/caller.go (oauth2-proxy header → context) stays — chassis
out-of-scope.

Net LOC change: -~150 LOC duplicated infra + a 5-LOC import.
go.mod gains gitea.d-ma.be/mathias/mcp-chassis v0.1.0 (jwx/v2 + testify
already transitive, no new top-level deps).

Verifies abort criterion: one PR, one binary's worth of port, task check
green (lint + test + vet + govulncheck clean). Per the S3 spike spec,
this clears the chassis to continue. Next port: hyperguild/ingestion
(brain-mcp), filed as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:25:23 +02:00
mathias 60212fc5d2 feat: issue_list + workflow_run_list tools (#28, #29)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Adds the *_list partners that the existing *_get tools have been
missing. Same pattern as repo_list — owner allowlisted, capLimit
helper for pagination, next_page surfaced when the page is full.

internal/gitea/issues.go:
- ListIssues(owner, repo, args) hitting
  GET /api/v1/repos/{owner}/{repo}/issues with type=issues server-side
  so PRs don't leak in (gitea conflates them on this endpoint).
- ListIssuesArgs struct: State, Labels, Since (ISO 8601), Page, Limit.

internal/gitea/workflows.go:
- ListWorkflowRuns(owner, repo, args) hitting
  GET /api/v1/repos/{owner}/{repo}/actions/runs.
- Expanded WorkflowRun struct with DisplayTitle, Event, HeadSHA,
  HeadBranch, WorkflowID, RunNumber, UpdatedAt, Actor so callers
  can pin runs to a commit / branch without a second lookup.
- ListWorkflowRunsArgs: Branch, HeadSHA, Status, Event, Workflow,
  Page, Limit. Status/Event 'all' treated as no-filter.

internal/tools/issue_list.go:
- Default state=open, default limit=30 (matches repo_list).
- next_page returned only when len(issues) == limit.

internal/tools/workflow_run_list.go:
- Default limit=10 (most common use is 'what just happened',
  not paging).
- Returns runs + total + optional next_page.

Tests: table-driven for both — happy path, empty result, filter
combinations, allowlist rejection. workflow_run_list also asserts
the 'status=all is no-op' behavior (no query param emitted).

Closes #28
Closes #29
2026-05-18 08:06:11 +02:00
mathias dc907fb7e0 feat: issue_close + issue_reopen tools (#30)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Adds two MCP tools that PATCH /api/v1/repos/{owner}/{name}/issues/{number}
with {"state":"closed"} or {"state":"open"}. Both use a shared
SetIssueState helper on the gitea client.

- internal/gitea/issues.go: SetIssueState method using the existing
  PatchJSON + MapStatus + json.Unmarshal pattern from GetIssue.
- internal/tools/issue_close.go: IssueClose tool. owner+name+number
  args. Owner allowlist enforced. Returns the updated issue. Reversible
  via issue_reopen, classified LOW risk.
- internal/tools/issue_reopen.go: mirror of IssueClose with
  state="open". Same risk profile.
- Registered both tools in cmd/gitea-mcp/main.go.
- Tests for both: success (asserts PATCH method, path, body), 404,
  and allowlist rejection — same shape as issue_get_test.go.

Closes #30
2026-05-18 07:51:17 +02:00
mathias c4bd3396c4 chore: re-sync context adapters from updated root AGENT.md 2026-05-18 07:51:17 +02:00
mathiasandClaude Opus 4.7 11f86f5d99 chore: adopt trunk-based development
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 3s
Closes #27.

PROJECT.md
- Git section: TBD as the convention. Commit to main, one logical
  change per commit, `task check` locally before push, CI is the
  quality gate. PRs only for the parallel-agent exception.
- Agent rule 6: rewritten to match.

.gitea/workflows/cd.yml
- Drop the pull_request trigger — vestigial under TBD.
- Drop the `if: github.event_name != 'pull_request'` guard on the
  build job (now always true since pull_request no longer fires).
  Tag pushes still build (no version gating regression).
- Deploy `if` left alone — already correctly limits deploy to
  main pushes, skipping tag-push builds.

.githooks/pre-push (new)
- Runs `task check` before every push. Set up via `task setup:hooks`,
  which sets core.hooksPath to the in-repo .githooks dir.

Taskfile.yml
- New `setup:hooks` task to install the pre-push hook on a fresh
  clone.

README.md
- Quickstart section showing `task setup:hooks` + the TBD policy.

Derived adapters regenerated via `task context:sync` and committed
in the same commit (single-commit invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:44:52 +02:00
mathias f7076c9ac8 docs: mark v0.2 complete, set next-up context for v0.2.5
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 4s
2026-05-17 09:26:47 +02:00
41 changed files with 2078 additions and 871 deletions
+95 -100
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "→ Running task check before push..."
task check
echo "✓ pre-push check passed"
+95 -100
View File
@@ -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
View File
@@ -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
View File
@@ -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 . .
+11
View File
@@ -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.
+7
View File
@@ -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:
+46
View File
@@ -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"
}
}
+60
View File
@@ -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)
})
}
}
+19 -54
View File
@@ -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)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
payload := map[string]any{
"resource": cfg.MCPResourceURL,
"authorization_servers": []string{},
}
if cfg.DexIssuerURL != "" { if cfg.DexIssuerURL != "" {
payload["authorization_servers"] = []string{cfg.DexIssuerURL} mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
} }
_ = json.NewEncoder(w).Encode(payload)
})
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0") logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
+1
View File
@@ -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
+2
View File
@@ -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=
-42
View File
@@ -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)
})
}
-92
View File
@@ -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)
}
-79
View File
@@ -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
}
+19
View File
@@ -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)
+54
View File
@@ -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)
}
+120
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strconv"
) )
type Issue struct { type Issue 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
+138
View File
@@ -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)
}
+67
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"strings" "strings"
) )
@@ -55,10 +56,20 @@ 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"`
DisplayTitle string `json:"display_title,omitempty"`
Status string `json:"status"` // queued | in_progress | completed Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed) Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
Event string `json:"event,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
HeadBranch string `json:"head_branch,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
RunNumber int64 `json:"run_number,omitempty"`
StartedAt string `json:"started_at"` StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at,omitempty"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
Actor struct {
Login string `json:"login"`
} `json:"actor,omitempty"`
} }
// GetWorkflowRun fetches the status of a specific Actions run. // GetWorkflowRun fetches the status of a specific Actions run.
@@ -77,3 +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
}
+49
View File
@@ -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`)
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueClose struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueClose(c *gitea.Client, a *allowlist.Allowlist) *IssueClose {
return &IssueClose{c: c, a: a}
}
func (t *IssueClose) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_close",
Description: "Close an open issue. Reversible via issue_reopen.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type issueCloseArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCloseArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "closed")
if err != nil {
return nil, err
}
return textOK(iss)
}
+52
View File
@@ -0,0 +1,52 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueCloseTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"closed"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"closed","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"closed"`)
}
func TestIssueCloseTool_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
tool := tools.NewIssueClose(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":999}`))
require.Error(t, err)
}
func TestIssueCloseAllowlistRejects(t *testing.T) {
tool := tools.NewIssueClose(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+80
View File
@@ -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,
})
}
+91
View File
@@ -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)
}
+83
View File
@@ -0,0 +1,83 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
return &IssueList{c: c, a: a}
}
func (t *IssueList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_list",
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"state":{"type":"string","enum":["open","closed","all"]},
"labels":{"type":"string"},
"since":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","name"]
}`),
}
}
type issueListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
State string `json:"state"`
Labels string `json:"labels"`
Since string `json:"since"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.State == "" {
args.State = "open"
}
args.Limit = capLimit(args.Limit, 30)
if args.Page < 1 {
args.Page = 1
}
issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, gitea.ListIssuesArgs{
State: args.State,
Labels: args.Labels,
Since: args.Since,
Page: args.Page,
Limit: args.Limit,
})
if err != nil {
return nil, err
}
out := map[string]any{
"issues": issues,
}
if len(issues) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type 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)
}
+88
View File
@@ -0,0 +1,88 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueListTool(t *testing.T) {
tests := []struct {
name string
input string
wantQuery map[string]string
respBody string
assert func(t *testing.T, out string)
}{
{
name: "happy path defaults",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"number":42`)
assert.Contains(t, out, `"number":41`)
},
},
{
name: "state filter",
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
wantQuery: map[string]string{"type": "issues", "state": "closed"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
},
},
{
name: "label + since filter",
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
respBody: `[]`,
assert: func(t *testing.T, out string) {},
},
{
name: "empty result",
input: `{"owner":"mathias","name":"infra"}`,
wantQuery: map[string]string{"state": "open"},
respBody: `[]`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"issues":[]`)
assert.NotContains(t, out, `next_page`)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
q := r.URL.Query()
for k, v := range tc.wantQuery {
assert.Equal(t, v, q.Get(k), "query param %q", k)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.respBody))
}))
defer srv.Close()
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
require.NoError(t, err)
tc.assert(t, string(out))
})
}
}
func TestIssueListAllowlistRejects(t *testing.T) {
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueReopen struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueReopen(c *gitea.Client, a *allowlist.Allowlist) *IssueReopen {
return &IssueReopen{c: c, a: a}
}
func (t *IssueReopen) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_reopen",
Description: "Reopen a closed issue. Reversible via issue_close.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type issueReopenArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueReopenArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Name, args.Number, "open")
if err != nil {
return nil, err
}
return textOK(iss)
}
+40
View File
@@ -0,0 +1,40 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueReopenTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/issues/26", r.URL.Path)
b, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"state":"open"}`, string(b))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":26,"title":"feat: ntfy via NPM","state":"open","html_url":"http://gitea.example.com/mathias/infra/issues/26"}`))
}))
defer srv.Close()
tool := tools.NewIssueReopen(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","number":26}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"number":26`)
assert.Contains(t, string(out), `"state":"open"`)
}
func TestIssueReopenAllowlistRejects(t *testing.T) {
tool := tools.NewIssueReopen(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","number":1}`))
require.Error(t, err)
}
+59
View File
@@ -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))
}
+58
View File
@@ -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
View File
@@ -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.
+87
View File
@@ -0,0 +1,87 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type WorkflowRunList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewWorkflowRunList(c *gitea.Client, a *allowlist.Allowlist) *WorkflowRunList {
return &WorkflowRunList{c: c, a: a}
}
func (t *WorkflowRunList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "workflow_run_list",
Description: "List recent Gitea Actions workflow runs with optional filters (branch, head_sha, status, event, workflow).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"branch":{"type":"string"},
"head_sha":{"type":"string"},
"status":{"type":"string","enum":["queued","in_progress","completed","all"]},
"event":{"type":"string","enum":["push","pull_request","schedule","workflow_dispatch","all"]},
"workflow":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner","name"]
}`),
}
}
type workflowRunListArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Branch string `json:"branch"`
HeadSHA string `json:"head_sha"`
Status string `json:"status"`
Event string `json:"event"`
Workflow string `json:"workflow"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args workflowRunListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 10)
if args.Page < 1 {
args.Page = 1
}
resp, err := t.c.ListWorkflowRuns(ctx, args.Owner, args.Name, gitea.ListWorkflowRunsArgs{
Branch: args.Branch,
HeadSHA: args.HeadSHA,
Status: args.Status,
Event: args.Event,
Workflow: args.Workflow,
Page: args.Page,
Limit: args.Limit,
})
if err != nil {
return nil, err
}
out := map[string]any{
"runs": resp.WorkflowRuns,
"total": resp.TotalCount,
}
if len(resp.WorkflowRuns) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
+98
View File
@@ -0,0 +1,98 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowRunListTool(t *testing.T) {
tests := []struct {
name string
input string
wantQuery map[string]string
notQuery []string
respBody string
assert func(t *testing.T, out string)
}{
{
name: "happy path defaults",
input: `{"owner":"mathias","name":"gitea-mcp"}`,
wantQuery: map[string]string{"page": "1", "limit": "10"},
respBody: `{"total_count":2,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"},{"id":822,"status":"completed","conclusion":"success","head_sha":"c4bd339"}]}`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"id":823`)
assert.Contains(t, out, `"total":2`)
},
},
{
name: "head_sha short filter",
input: `{"owner":"mathias","name":"gitea-mcp","head_sha":"dc907fb"}`,
wantQuery: map[string]string{"head_sha": "dc907fb"},
respBody: `{"total_count":1,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"}]}`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"id":823`)
},
},
{
name: "status filter",
input: `{"owner":"mathias","name":"gitea-mcp","status":"in_progress"}`,
wantQuery: map[string]string{"status": "in_progress"},
respBody: `{"total_count":0,"workflow_runs":[]}`,
assert: func(t *testing.T, out string) {
assert.Contains(t, out, `"runs":[]`)
},
},
{
name: "status=all is no-op",
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
notQuery: []string{"status"},
respBody: `{"total_count":0,"workflow_runs":[]}`,
assert: func(t *testing.T, out string) {},
},
{
name: "branch filter",
input: `{"owner":"mathias","name":"gitea-mcp","branch":"main"}`,
wantQuery: map[string]string{"branch": "main"},
respBody: `{"total_count":0,"workflow_runs":[]}`,
assert: func(t *testing.T, out string) {},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/gitea-mcp/actions/runs", r.URL.Path)
q := r.URL.Query()
for k, v := range tc.wantQuery {
assert.Equal(t, v, q.Get(k), "query param %q", k)
}
for _, k := range tc.notQuery {
assert.Equal(t, "", q.Get(k), "query param %q should be absent", k)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.respBody))
}))
defer srv.Close()
tool := tools.NewWorkflowRunList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
require.NoError(t, err)
tc.assert(t, string(out))
})
}
}
func TestWorkflowRunListAllowlistRejects(t *testing.T) {
tool := tools.NewWorkflowRunList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}