Compare commits

..
18 Commits
Author SHA1 Message Date
mathias 5c6c4f1e1e fix(pr_merge): send Gitea's merge_title_field (gitea-mcp#77) (#82)
CD / Deploy via GitOps (push) Successful in 7s
CD / Lint / Test / Vet (push) Successful in 12s
CD / Build & Import (push) Successful in 5s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-10 08:50:06 +00:00
mathias e8521c2555 chore(context): restore the root rules in AGENTS.md and system-prompt.txt (#80) (#81)
CD / Lint / Test / Vet (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 5s
CD / Build & Import (push) Successful in 17s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-09 06:02:43 +00:00
mathiasandClaude Sonnet 5.5 e696db2e68 docs: main is protected, ship by PR (local-dev#42)
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 5s
CD / Deploy via GitOps (push) Successful in 5s
Gitea will refuse direct pushes to main in this repo. Rule 5's reviewed lane was enforced on the merge tools
(tbd_ship / pr_merge, gitea-mcp v0.17.0/v0.18.0) but not on a direct commit; this overrides rule 5's "risk/low straight
to main" for this repo and says why. Derived adapter files regenerated where the repo has them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:34:50 +02:00
mathias d1e501cc64 feat(pr_merge): gate it like tbd_ship — green CI, unprotected base, exactly one risk/low issue (#74) (#76)
CD / Lint / Test / Vet (push) Successful in 15s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Skipped
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-08 06:18:07 +00:00
mathias d3faffc4c4 feat(tbd_ship): gate auto-merge on the risk tier; unclassified is not low (#63) (#73)
CD / Build & Import (push) Successful in 4s
CD / Lint / Test / Vet (push) Successful in 11s
CD / Deploy via GitOps (push) Skipped
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-08 06:02:34 +00:00
mathiasandClaude Opus 5.5 dbf78a3b34 ci: move template expressions out of run: shells (signals#47)
CD / Lint / Test / Vet (push) Successful in 12s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Successful in 5s
"Derive image tags" expanded ${{ github.ref }} into the run: body. A
ref name may contain $(...) and the expansion happens before the shell
parses the script, so it now arrives via env: REF instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:33:44 +02:00
mathias c3f8d6c002 ci: run the check job on pull requests (#68) (#69)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 6s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 07:25:38 +00:00
mathias dc127a05b5 Merge pull request 'fix(tools): reject case-variant arg keys and ref-shaped branch names (#65)' (#66) from fix/65-strict-args-branch-names into main
CD / Lint / Test / Vet (push) Successful in 14s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Skipped
2026-10-05 05:10:51 +00:00
mathiasandClaude Opus 5.5 cd61239451 perf(tools): linear duplicate-key scan keyed on SimpleFold orbit
PR #66 review round 2: the pairwise EqualFold loop was quadratic, 7s of
CPU for ~90k keys under the 1 MiB body cap. foldKey maps each rune to
the smallest member of its unicode.SimpleFold orbit, equal exactly when
strings.EqualFold is, so the scan is a map lookup again. The scaling
test fails on the previous code (7.1s).

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:00 +02:00
mathiasandClaude Opus 5.5 31a3dd3e0e fix(tools): fail closed when the key scan cannot read the input
PR #66 review: json.Decoder.Token() fails on a number outside float64
range (1e999), which json.Unmarshal ignores in an unknown key, so a
decoy key ended the scan and the #65 repro wrote to main. The decoder
now uses UseNumber, and any scan error is a validation error. The
duplicate check folds with strings.EqualFold (as encoding/json does)
instead of ToLower, so long s and the Kelvin sign are caught in nested
objects too.

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:02:27 +02:00
mathiasandClaude Opus 5.5 ae50501ba3 fix(tools): reject case-variant arg keys and ref-shaped branch names
parseArgs now refuses, before any request: two keys equal under
strings.EqualFold in any object (exact duplicates included), and a
top-level key that differs from a schema field only in case. Without
this, encoding/json's case-insensitive last-wins match let
{"branch":"feat/x","Branch":"main"} write to main while a client-side
hook read feat/x.

file_write_branch (branch) and tbd_ship (branch and base) validate the
branch name: ^[A-Za-z0-9][A-Za-z0-9._/-]*$ plus no refs/ prefix, HEAD,
"..", "//", "/.", trailing "/" or ".", or ".lock". refs/heads/main
otherwise created refs/heads/refs/heads/main and wedged API writes to
main. tbd_ship's base is the protection-probe key, so a refs/-prefixed
base would also have read as unprotected.

Closes #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:58:31 +02:00
mathiasandClaude Opus 5.5 9396b4daaa chore(context): resync derived adapters from canonical AGENT.md
CD / Lint / Test / Vet (push) Successful in 28s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 6s
Clears ContextAdapterDrift; canonical changed in local-dev 12f694b/218cf0d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:49:57 +02:00
mathiasandClaude Opus 5.5 0e2bd3d90e fix(mcp): audit record keeps legacy name/index alias targets
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 15s
CD / Build & Import (push) Successful in 7s
A caller using the #38 aliases (name for repo, index for number) left no
target in the tools/call audit record.

Refs #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:41:51 +02:00
mathiasandClaude Opus 5.5 b149dccdbe chore(context): re-sync adapters after root brain-scoping correction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:41:47 +02:00
mathiasandClaude Opus 5.5 724a997002 feat(mcp): audit-log every tools/call (OWASP MCP08)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 31s
CD / Build & Import (push) Successful in 14s
tools/call dispatched with no record, so repo_delete, pr_merge and
file_delete left no trail in gitea-mcp; only auth acceptance was logged.

One slog record per call: tool, caller, token fingerprint, target
(owner/repo/number/branch/path/tag), outcome, duration. Every other
argument is reduced to args_bytes so the audit line never copies file
content or issue bodies.

Closes #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:34:59 +02:00
mathiasandClaude Opus 5 59dabc9455 chore(context): adopt canonical context-sync.sh and re-sync adapters
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 17s
CD / Deploy via GitOps (push) Successful in 5s
infra's scripts/context-sync.sh is the estate canonical, and it moved in infra
262369b (a SIGPIPE fix on the placeholder-reporting path: `printf | grep -o |
head -1` became `head -1 < <(grep -o ...)`). Every other repo still carried the
old line, so ContextSyncScriptDrift and ContextAdapterDrift were both firing.

Also picks up the canonical AGENT.md change from local-dev a01ee80 — rule 5's
GitOps review lane and the new precedence rule — which every repo's adapters
need regenerated for regardless of the script.

Refs mathias/infra#470

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:36:32 +02:00
mathiasandClaude Opus 5 6b57a8ba8a chore(context): regenerate derived adapters from the canonical root context
CD / Lint / Test / Vet (push) Successful in 41s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 10s
ContextAdapterDrift was firing for 13 repos. Picks up behaviour rule 7
("Finish what's closable"), which landed in ~/dev/.context/AGENT.md and never
reached any repo's derived files.

Where mcp.json changes, it also picks up root MCP config the repo was stale on:
brain/gitea/infra move from public hostnames to tailnet Serve URLs, and gitea's
bearer changes from GITEA_MCP_TOKEN to DMABE_GITEA_API_TOKEN — gitea-mcp
authenticates by pass-through and 401s on the former, so a harness syncing from
the old file had a dead gitea server and no signal saying so.

No literal secrets: mcp.json carries ${VAR} placeholders only.

CLAUDE.md deliberately unchanged — context-sync writes it from PROJECT.md alone,
since Claude Code walks up to ~/dev/CLAUDE.md for root context.

Refs mathias/infra ContextAdapterDrift

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 07:34:24 +02:00
mathiasandClaude Opus 5 e1e9a2e30e feat(auth): every accepted request names the path that granted it
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 16s
CD / Build & Import (push) Successful in 16s
Closes #60. Closes #58 (fixed in mcp-chassis v0.7.0, consumed here).

Three auth paths reach this server and none of them said so on success.

PassthroughMiddleware logged nothing at all, success or failure. While verifying
#59 end to end there was no way to confirm from the logs whether calls were
using the new PAT pass-through — the caller's own Gitea PAT, the caller's own
identity — or falling through to the shared JWT/static path. The only available
reasoning was inference from which failures were absent.

    INFO mcp auth accepted auth_path=passthrough caller=alice token_fp=...
    INFO mcp auth accepted auth_path=jwt        caller=mathiasbq token_fp=...
    INFO mcp auth accepted auth_path=static     token_fp=...

The first line matters more than the other two. Pass-through is the path where a
request acts as a SPECIFIC Gitea user; an audit trail that cannot say which
identity performed a write is not an audit trail.

chassisauth.Fingerprint, not a local equivalent. The chassis emits token_fp on
its own accepted and rejected lines, and two different fingerprints for the same
token cannot be correlated — which is the only reason the field exists. Exported
from the chassis in v0.7.1 for exactly this.

A token that is not a valid PAT still falls through SILENTLY. The chassis owns
the verdict, and logging here as well would double-count every static-token
request, since all of them reach this middleware first. Asserted, because "logs
nothing on the failure path" is indistinguishable from "was never called".

Also lands #58 by consuming mcp-chassis v0.7.0: a JWT presented to a server with
no JWT auth configured now reports jwt_auth_not_configured rather than
static_token_mismatch. That log line is what sent a debugging session into
re-running the OAuth flow while the credentials were valid the whole time.

Verified: task check EXIT=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:59:04 +02:00
24 changed files with 1791 additions and 155 deletions
+16 -4
View File
@@ -37,9 +37,9 @@
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - **Trunk-Based Development:** one logical change per commit, merged to main by PR (`main` is protected, see below).
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work. - Short-lived branches and PRs are how work reaches `main` here (`tbd_ship`/`pr_merge`); no long-lived feature branches.
- Exception: if a parallel agent session is active on this repo, use a short-lived - Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session. `agent/<description>` branch and merge within the same session.
@@ -79,8 +79,8 @@ 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. Commit directly to main. Run `task check` before every push. Never create 6. Ship by PR (`main` is protected, see below). Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo. long-lived feature branches.
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 state — v0.2.5 (2026-05-17) ## Current state — v0.2.5 (2026-05-17)
@@ -105,3 +105,15 @@ Current main: `e31fd3f`. CI green. Deployed via Flux.
2. **Issue #19** — end-to-end mirror flow verification. 2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production. `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.
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CD / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/gitea-mcp/branch_protections/main {"enable_push": true}`.
+165 -25
View File
@@ -78,6 +78,27 @@ These rules apply to every task across every project, regardless of harness.
when a human reviewer outside the project is required. Document the reason in when a human reviewer outside the project is required. Document the reason in
PROJECT.md. PROJECT.md.
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
PR would buy a review of a state that was never deployed.
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
thread.** No verdict recorded means the lane was not used, and saying "the gate was
green" is not the same claim.
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
is an agent. It is more than the repo had and less than the model it mirrors; do not
describe it as the stronger property. (infra#470, decided 2026-09-29.)
This lane depends on the `risk/` label being right. A label that triggers a heavier
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
lane silently does not apply.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping 6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted: the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
@@ -106,6 +127,100 @@ These rules apply to every task across every project, regardless of harness.
revisit Y". Those are exactly the items that need a locator. If it is worth revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you. saying in a summary, it is worth writing where it can be found again without you.
7. **Finish what's closable — only involve me for a genuine call.** If a
tracker issue, PR, or checklist item is demonstrably resolved — the fix
landed, the state changed, you verified it live — close/merge/check it off
in the same turn. Don't leave it open "for the record" or park it waiting
on me to confirm something that already happened; that's bookkeeping, not
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
comment, or otherwise pausing) for when it's genuinely mine to call:
irreversible or prod/host-impacting actions, a real judgment call with no
clearly-better option, ambiguous intent, or anything secret-writes/
destructive-git already gate. Before claiming something is closable,
verify it live (re-check the issue, re-run the command) rather than
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
8. **Control code needs an adversary, not a checklist.** Code whose job is to
refuse, deny, gate or contain is not adequately verified by a green gate.
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
passing suite only asserts what its author already thought of, and for a
control the author is precisely the person whose imagination just failed.
Measured on `dispatch#75`: five review rounds, five real escapes verified by
execution, and **CI was green on every one of them** while `git rm
././.dispatch-allow` deleted the file the control existed to protect. So
route this work through a review with authority to block, instructed to
verify by execution rather than by reading the implementation. Expect to be
blocked more than once; that is the control working, not the reviewer being
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
practices live in the `pr-gatekeeper` agent.)
9. **A test's name must not claim more than its assertions prove.** Name a test
after the mechanism it observes, not the goal it serves.
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
support. This is not pedantry about naming — when name and assertion
diverge, the name wins in the reader's head and the gap becomes invisible,
which is what lets the defect survive review. Found three times in one PR,
twice in tests written to fix the previous instance of it; each passed while
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -186,25 +301,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -246,10 +356,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|---------|-------|-------| |---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | | **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml` fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the on the koala k3s cluster; don't hardcode local-only model names into the
@@ -319,8 +435,20 @@ Workflow:
and missing-untracked adapters). A drift fails the check with a and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files. message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
unconditionally on every host, every harness. host and every harness.
**Where a project rule contradicts a rule here, the project rule governs — and it
must state its reason.** Both halves matter: the project file wins because it knows
something this file cannot (infra's Flux constraint is real and not visible from
here), and it must say why because a bare override is indistinguishable from drift.
This replaces the previous wording, which said rules here and in `PROJECT.md` both
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
at the same time (infra#470). A standard can hold two individually sensible clauses
that are jointly unsatisfiable, and survive for as long as everyone reads it
charitably — the same failure the Inception Sprint Oath's G6 had.
## Engineering Skills ## Engineering Skills
@@ -390,9 +518,9 @@ Check a host with `task doctor` in the skills repo; it also runs automatically a
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - **Trunk-Based Development:** one logical change per commit, merged to main by PR (`main` is protected, see below).
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work. - Short-lived branches and PRs are how work reaches `main` here (`tbd_ship`/`pr_merge`); no long-lived feature branches.
- Exception: if a parallel agent session is active on this repo, use a short-lived - Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session. `agent/<description>` branch and merge within the same session.
@@ -432,8 +560,8 @@ 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. Commit directly to main. Run `task check` before every push. Never create 6. Ship by PR (`main` is protected, see below). Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo. long-lived feature branches.
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 state — v0.2.5 (2026-05-17) ## Current state — v0.2.5 (2026-05-17)
@@ -459,4 +587,16 @@ Current main: `e31fd3f`. CI green. Deployed via Flux.
2. **Issue #19** — end-to-end mirror flow verification. 2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production. `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.
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CD / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/gitea-mcp/branch_protections/main {"enable_push": true}`.
--- ---
+10 -1
View File
@@ -8,6 +8,12 @@ name: CD
# (Only applies to branch pushes; tag pushes always trigger.) # (Only applies to branch pushes; tag pushes always trigger.)
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
# gitea-mcp#68: without this a PR never got a CI run, so the risk/medium+
# PR lane merged on local `task check` alone. Only the check job runs for a
# PR -- build and deploy stay push/tag-only (no images, no infra writes,
# no secrets from a PR).
pull_request:
branches: [main]
env: env:
IMAGE: gitea-mcp IMAGE: gitea-mcp
@@ -44,6 +50,7 @@ jobs:
build: build:
name: Build & Import name: Build & Import
needs: check needs: check
if: github.event_name == 'push'
runs-on: self-hosted runs-on: self-hosted
outputs: outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }} image-tag: ${{ steps.meta.outputs.sha-tag }}
@@ -52,10 +59,12 @@ jobs:
- name: Derive image tags - name: Derive image tags
id: meta id: meta
env:
# signals#47: a ref name can carry $(...); pass it via env, not ${{ }}.
REF: ${{ github.ref }}
run: | run: |
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT" echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT" echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "version=${REF#refs/tags/}" >> "$GITHUB_OUTPUT" echo "version=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
+165 -25
View File
@@ -73,6 +73,27 @@ These rules apply to every task across every project, regardless of harness.
when a human reviewer outside the project is required. Document the reason in when a human reviewer outside the project is required. Document the reason in
PROJECT.md. PROJECT.md.
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
PR would buy a review of a state that was never deployed.
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
thread.** No verdict recorded means the lane was not used, and saying "the gate was
green" is not the same claim.
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
is an agent. It is more than the repo had and less than the model it mirrors; do not
describe it as the stronger property. (infra#470, decided 2026-09-29.)
This lane depends on the `risk/` label being right. A label that triggers a heavier
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
lane silently does not apply.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping 6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted: the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
@@ -101,6 +122,100 @@ These rules apply to every task across every project, regardless of harness.
revisit Y". Those are exactly the items that need a locator. If it is worth revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you. saying in a summary, it is worth writing where it can be found again without you.
7. **Finish what's closable — only involve me for a genuine call.** If a
tracker issue, PR, or checklist item is demonstrably resolved — the fix
landed, the state changed, you verified it live — close/merge/check it off
in the same turn. Don't leave it open "for the record" or park it waiting
on me to confirm something that already happened; that's bookkeeping, not
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
comment, or otherwise pausing) for when it's genuinely mine to call:
irreversible or prod/host-impacting actions, a real judgment call with no
clearly-better option, ambiguous intent, or anything secret-writes/
destructive-git already gate. Before claiming something is closable,
verify it live (re-check the issue, re-run the command) rather than
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
8. **Control code needs an adversary, not a checklist.** Code whose job is to
refuse, deny, gate or contain is not adequately verified by a green gate.
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
passing suite only asserts what its author already thought of, and for a
control the author is precisely the person whose imagination just failed.
Measured on `dispatch#75`: five review rounds, five real escapes verified by
execution, and **CI was green on every one of them** while `git rm
././.dispatch-allow` deleted the file the control existed to protect. So
route this work through a review with authority to block, instructed to
verify by execution rather than by reading the implementation. Expect to be
blocked more than once; that is the control working, not the reviewer being
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
practices live in the `pr-gatekeeper` agent.)
9. **A test's name must not claim more than its assertions prove.** Name a test
after the mechanism it observes, not the goal it serves.
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
support. This is not pedantry about naming — when name and assertion
diverge, the name wins in the reader's head and the gap becomes invisible,
which is what lets the defect survive review. Found three times in one PR,
twice in tests written to fix the previous instance of it; each passed while
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -181,25 +296,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -241,10 +351,16 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|---------|-------|-------| |---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | | **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml` fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the on the koala k3s cluster; don't hardcode local-only model names into the
@@ -314,8 +430,20 @@ Workflow:
and missing-untracked adapters). A drift fails the check with a and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files. message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
unconditionally on every host, every harness. host and every harness.
**Where a project rule contradicts a rule here, the project rule governs — and it
must state its reason.** Both halves matter: the project file wins because it knows
something this file cannot (infra's Flux constraint is real and not visible from
here), and it must say why because a bare override is indistinguishable from drift.
This replaces the previous wording, which said rules here and in `PROJECT.md` both
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
at the same time (infra#470). A standard can hold two individually sensible clauses
that are jointly unsatisfiable, and survive for as long as everyone reads it
charitably — the same failure the Inception Sprint Oath's G6 had.
## Engineering Skills ## Engineering Skills
@@ -385,9 +513,9 @@ Check a host with `task doctor` in the skills repo; it also runs automatically a
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - **Trunk-Based Development:** one logical change per commit, merged to main by PR (`main` is protected, see below).
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work. - Short-lived branches and PRs are how work reaches `main` here (`tbd_ship`/`pr_merge`); no long-lived feature branches.
- Exception: if a parallel agent session is active on this repo, use a short-lived - Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session. `agent/<description>` branch and merge within the same session.
@@ -427,8 +555,8 @@ 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. Commit directly to main. Run `task check` before every push. Never create 6. Ship by PR (`main` is protected, see below). Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo. long-lived feature branches.
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 state — v0.2.5 (2026-05-17) ## Current state — v0.2.5 (2026-05-17)
@@ -453,3 +581,15 @@ Current main: `e31fd3f`. CI green. Deployed via Flux.
2. **Issue #19** — end-to-end mirror flow verification. 2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production. `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.
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CD / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/gitea-mcp/branch_protections/main {"enable_push": true}`.
+16 -4
View File
@@ -37,9 +37,9 @@
### Git ### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit. - **Trunk-Based Development:** one logical change per commit, merged to main by PR (`main` is protected, see below).
- Run `task check` locally before every push. CI is the quality gate, not branch protection. - Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work. - Short-lived branches and PRs are how work reaches `main` here (`tbd_ship`/`pr_merge`); no long-lived feature branches.
- Exception: if a parallel agent session is active on this repo, use a short-lived - Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session. `agent/<description>` branch and merge within the same session.
@@ -79,8 +79,8 @@ 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. Commit directly to main. Run `task check` before every push. Never create 6. Ship by PR (`main` is protected, see below). Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo. long-lived feature branches.
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 state — v0.2.5 (2026-05-17) ## Current state — v0.2.5 (2026-05-17)
@@ -105,3 +105,15 @@ Current main: `e31fd3f`. CI green. Deployed via Flux.
2. **Issue #19** — end-to-end mirror flow verification. 2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production. `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.
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CD / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/gitea-mcp/branch_protections/main {"enable_push": true}`.
+16
View File
@@ -125,6 +125,22 @@ by area:
| Search / Templates | code_search, create_project_from_template | | Search / Templates | code_search, create_project_from_template |
| Composite | tbd_ship (trunk-based ship helper) | | Composite | tbd_ship (trunk-based ship helper) |
**Merge gate: `tbd_ship` (gitea-mcp#63) and `pr_merge` (gitea-mcp#74).** The two merge tools share one gate
(`evaluateShipGate`). A PR is merged only when ALL of: the head commit's CI is fully green, the base branch is not
review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`. No `issue`, no
`risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that cannot be read
all fail closed: nothing is merged, the result is `merged:false` with a `reason`, the `ci_status` and the `risk` tier.
Unclassified is not low. Above `risk/low` a human reviews and merges in the Gitea UI (which this gate does not touch).
`pr_merge` additionally fails with an error, merging nothing, when the PR or the base's branch-protection state cannot
be read. A merge conflict is still an error.
Both tools bind the merge to the head commit whose CI the gate evaluated (`head_commit_id`), so a push to the PR
branch after the check makes Gitea refuse the merge instead of merging unchecked code.
Limits: the gate enforces the tier of the issue the caller names; it cannot prove that issue is the one the change
belongs to, so the link is the caller's claim (local-dev rule 5's convention still applies). Not gated: merging in the
Gitea UI, and `file_write_branch` committing directly to a branch (`main` included), which is how `risk/low` work
reaches trunk. Platform-level enforcement for those is branch protection on `main` (local-dev#27 option 2).
Shared tool-layer hygiene (`internal/tools/tool.go`): Shared tool-layer hygiene (`internal/tools/tool.go`):
- **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so - **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so
a tool works whichever spelling a caller sends (an explicit canonical wins). a tool works whichever spelling a caller sends (an explicit canonical wins).
+1 -1
View File
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
go 1.26.2 go 1.26.2
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.6.0 git.d-ma.be/mathias/mcp-chassis v0.7.1
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
+4
View File
@@ -2,6 +2,10 @@ git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bT
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg= git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.7.0 h1:ct31v1F1PMhMCva/V0ErEbgMzwnT+27lYKbug2A/quE=
git.d-ma.be/mathias/mcp-chassis v0.7.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+26
View File
@@ -2,9 +2,12 @@ package auth
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"strings" "strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/gitea"
) )
@@ -25,11 +28,34 @@ func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Hand
token, hasBearer := strings.CutPrefix(authz, "Bearer ") token, hasBearer := strings.CutPrefix(authz, "Bearer ")
if hasBearer && token != "" { if hasBearer && token != "" {
if login, ok := validator.ValidateToken(r.Context(), token); ok { if login, ok := validator.ValidateToken(r.Context(), token); ok {
// gitea-mcp#60. This middleware logged nothing at all, so there
// was no way to tell a PAT pass-through request from one that
// fell through to the shared JWT/static path — only inference
// from which failures were absent.
//
// It matters more here than on the other two paths: this is
// where a request acts as a SPECIFIC Gitea user. An audit trail
// that cannot say which identity performed a write is not an
// audit trail.
//
// chassisauth.Fingerprint, not a local equivalent: the chassis
// emits the same field on its own accepted/rejected lines, and
// two different fingerprints for one token cannot be correlated.
slog.Info("mcp auth accepted",
"auth_path", "passthrough",
"caller", login,
"token_fp", chassisauth.Fingerprint(token),
"path", r.URL.Path,
)
ctx := withCaller(r.Context(), login) ctx := withCaller(r.Context(), login)
ctx = gitea.WithToken(ctx, token) ctx = gitea.WithToken(ctx, token)
onValid.ServeHTTP(w, r.WithContext(ctx)) onValid.ServeHTTP(w, r.WithContext(ctx))
return return
} }
// A token that is not a valid PAT falls through SILENTLY. The
// chassis owns the verdict; logging here too would double-count
// every static-token request, since all of them reach this
// middleware first.
} }
fallback.ServeHTTP(w, r) fallback.ServeHTTP(w, r)
}) })
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type stubValidator struct{ login string }
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
return s.login, s.login != ""
}
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
//
// While verifying #59 end to end there was no way to confirm from the server
// logs whether calls were using the new PAT pass-through (caller's own Gitea
// PAT, own identity) or falling through to the shared JWT/static path. The only
// way to reason about it was inference. The chassis names its two paths as of
// v0.7.0; this is the third.
//
// This matters more than the other two: pass-through is the path where the
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
// identity performed a write is not an audit trail.
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
reached := false
h := PassthroughMiddleware(stubValidator{login: "alice"},
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
require.True(t, reached)
var line map[string]any
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
require.Equal(t, "mcp auth accepted", line["msg"])
require.Equal(t, "passthrough", line["auth_path"])
require.Equal(t, "alice", line["caller"])
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
for k, v := range line {
require.NotContains(t, []any{v}, "a-real-gitea-pat",
"the PAT must never appear in any field, including %q", k)
}
}
// A token that is not a valid PAT must fall through silently to the chassis,
// which owns the rejection log. Logging here as well would double-count every
// static-token request — every one of them reaches this middleware first.
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
fellBack := false
h := PassthroughMiddleware(stubValidator{},
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
req.Header.Set("Authorization", "Bearer not-a-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
require.True(t, fellBack)
require.Empty(t, strings.TrimSpace(buf.String()),
"the chassis owns the verdict; logging here too would double-count every static-token call")
}
+3 -1
View File
@@ -107,8 +107,10 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, ind
type MergePRArgs struct { type MergePRArgs struct {
Do string `json:"Do"` Do string `json:"Do"`
Title string `json:"merge_message_title,omitempty"` Title string `json:"merge_title_field,omitempty"`
Body string `json:"merge_message_field,omitempty"` Body string `json:"merge_message_field,omitempty"`
// HeadCommitID binds the merge to the commit whose CI was checked: if the PR branch moved, Gitea refuses.
HeadCommitID string `json:"head_commit_id,omitempty"`
} }
func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, index int, args MergePRArgs) error { func (c *Client) MergePullRequest(ctx context.Context, owner, repo string, index int, args MergePRArgs) error {
+46
View File
@@ -0,0 +1,46 @@
package mcp
import (
"context"
"encoding/json"
"log/slog"
"time"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// auditTargetKeys are the only argument values copied into the audit record:
// they identify what a call acted on without copying what it wrote. Every
// other argument (file content, issue bodies) is reduced to args_bytes so the
// audit trail never becomes a second copy of repo content (gitea-mcp#64).
var auditTargetKeys = []string{"owner", "repo", "name", "number", "index", "branch", "path", "tag"}
// logToolCall emits one structured record per tools/call (OWASP MCP08).
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
attrs := []any{
"tool", name,
"caller", auth.Caller(ctx),
"args_bytes", len(args),
"duration_ms", time.Since(start).Milliseconds(),
}
if tok, ok := gitea.TokenFromContext(ctx); ok {
attrs = append(attrs, "token_fp", chassisauth.Fingerprint(tok))
}
var parsed map[string]any
if json.Unmarshal(args, &parsed) == nil {
for _, k := range auditTargetKeys {
if v, ok := parsed[k]; ok {
attrs = append(attrs, k, v)
}
}
}
outcome := "ok"
if err != nil {
outcome = err.Error()
}
attrs = append(attrs, "outcome", outcome)
slog.InfoContext(ctx, "mcp tool call", attrs...)
}
+135
View File
@@ -0,0 +1,135 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type auditStubTool struct {
name string
err error
}
func (s auditStubTool) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{Name: s.name, InputSchema: json.RawMessage(`{"type":"object"}`)}
}
func (s auditStubTool) Call(context.Context, json.RawMessage) (json.RawMessage, error) {
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"ok":true}`), nil
}
// captureAudit routes slog to a buffer and returns the decoded
// "mcp tool call" records emitted while fn runs.
func captureAudit(t *testing.T, fn func()) []map[string]any {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
fn()
var out []map[string]any
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if l == "" {
continue
}
var rec map[string]any
require.NoError(t, json.Unmarshal([]byte(l), &rec))
if rec["msg"] == "mcp tool call" {
out = append(out, rec)
}
}
return out
}
func auditServer(tools ...registry.Tool) http.Handler {
reg := registry.New()
for _, tl := range tools {
reg.Register(tl)
}
srv := mcp.NewServer(mcp.ServerOptions{Registry: reg})
return auth.CallerMiddleware(slog.Default(), srv)
}
func callTool(t *testing.T, h http.Handler, name string, args any) {
t.Helper()
body := map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Request-User", "alice")
h.ServeHTTP(httptest.NewRecorder(), req)
}
// gitea-mcp#64: tools/call dispatched with no record at all.
func TestToolsCallLogsOneAuditRecordWithToolCallerAndTarget(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "repo_delete"}), "repo_delete",
map[string]any{"owner": "mathias", "repo": "scratch", "confirm": "scratch"})
})
require.Len(t, recs, 1)
r := recs[0]
assert.Equal(t, "repo_delete", r["tool"])
assert.Equal(t, "alice", r["caller"])
assert.Equal(t, "mathias", r["owner"])
assert.Equal(t, "scratch", r["repo"])
assert.Equal(t, "ok", r["outcome"])
assert.Contains(t, r, "duration_ms")
}
func TestToolsCallAuditRecordsErrorOutcome(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "pr_merge", err: errors.New("boom")}),
"pr_merge", map[string]any{"owner": "mathias", "repo": "x", "number": 3})
})
require.Len(t, recs, 1)
assert.Equal(t, "boom", recs[0]["outcome"])
assert.EqualValues(t, 3, recs[0]["number"])
}
// The audit line must not become a second copy of repo content.
func TestToolsCallAuditOmitsArgumentValuesOtherThanTarget(t *testing.T) {
const sentinel = "SENTINEL-FILE-CONTENT-7f3a"
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "file_write_branch"}), "file_write_branch",
map[string]any{"owner": "mathias", "repo": "x", "path": "a.txt", "content": sentinel})
})
require.Len(t, recs, 1)
raw, err := json.Marshal(recs[0])
require.NoError(t, err)
assert.NotContains(t, string(raw), sentinel)
assert.Contains(t, recs[0], "args_bytes")
}
// #38 keeps `name` (for repo) and `index` (for number) as legacy aliases; a
// caller using them must still leave a target in the audit record.
func TestToolsCallAuditRecordsLegacyAliasTargets(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "issue_close"}), "issue_close",
map[string]any{"owner": "mathias", "name": "infra", "index": 7})
})
require.Len(t, recs, 1)
assert.Equal(t, "infra", recs[0]["name"])
assert.EqualValues(t, 7, recs[0]["index"])
}
+3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "git.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
@@ -90,7 +91,9 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
NewErrorResponse(req.ID, -32602, "invalid params", nil)) NewErrorResponse(req.ID, -32602, "invalid params", nil))
return return
} }
start := time.Now()
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments) out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
logToolCall(r.Context(), p.Name, p.Arguments, start, err)
if err != nil { if err != nil {
code := -32000 code := -32000
if errors.Is(err, registry.ErrToolNotFound) { if errors.Is(err, registry.ErrToolNotFound) {
+3
View File
@@ -63,6 +63,9 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if args.Branch == "" { if args.Branch == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation) return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
} }
if err := ValidateBranchName(args.Branch); err != nil {
return nil, err
}
if args.Message == "" { if args.Message == "" {
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation) return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
} }
+42
View File
@@ -0,0 +1,42 @@
package tools
import (
"fmt"
"strings"
"testing"
"time"
)
// foldKey must agree with strings.EqualFold, which is how encoding/json matches.
func TestFoldKeyAgreesWithEqualFold(t *testing.T) {
pairs := [][2]string{{"branch", "Branch"}, {"sha", "ſha"}, {"k", "K"}, {"ss", "ß"}, {"a", "b"}, {"Σ", "ς"}}
for _, p := range pairs {
if got, want := foldKey(p[0]) == foldKey(p[1]), strings.EqualFold(p[0], p[1]); got != want {
t.Errorf("%q vs %q: foldKey equal=%v, EqualFold=%v", p[0], p[1], got, want)
}
}
}
// The duplicate scan is linear in the number of keys: 90k keys (about the
// 1 MiB body cap) must scan well under a second.
func TestRejectAmbiguousKeysScalesLinearly(t *testing.T) {
var b strings.Builder
b.WriteString("{")
for i := 0; i < 90_000; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `"k%d":1`, i)
}
b.WriteString("}")
var dst struct {
Owner string `json:"owner"`
}
start := time.Now()
if err := rejectAmbiguousKeys([]byte(b.String()), &dst); err != nil {
t.Fatal(err)
}
if d := time.Since(start); d > 2*time.Second {
t.Errorf("scan of 90k keys took %v", d)
}
}
+45 -4
View File
@@ -10,6 +10,9 @@ import (
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "git.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
// PRMerge merges an existing PR behind the same gate as tbd_ship (gitea-mcp#74, #63): CI fully green, base not
// review-protected, and the issue named in `issue` classified exactly risk/low. Above risk/low a human reviews and
// merges in the Gitea UI; this tool then merges nothing and says why.
type PRMerge struct { type PRMerge struct {
c *gitea.Client c *gitea.Client
a *allowlist.Allowlist a *allowlist.Allowlist
@@ -22,16 +25,18 @@ func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge {
func (t *PRMerge) Descriptor() registry.ToolDescriptor { func (t *PRMerge) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "pr_merge", Name: "pr_merge",
Description: "Merge a pull request. style: merge (default), squash, or rebase.", Description: "Merge a pull request, gated like tbd_ship: it merges ONLY when the head commit's CI is fully green AND the base branch is not review-protected AND the issue named in `issue` carries exactly one risk/ label, risk/low. Otherwise it merges nothing and returns merged:false with a reason and the risk tier (no issue named, no or several risk/ labels, an unknown value, risk/medium, risk/high, an unreadable issue, CI pending/red/absent, a review-protected base). Above risk/low a human reviews and merges in the Gitea UI. style: merge (default), squash, or rebase. Set ci_timeout_seconds to poll CI to completion (default 0 = single snapshot). Returns {merged, pr_number, ci_status, risk, reason}.",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "repo":{"type":"string"},
"number":{"type":"integer","minimum":1}, "number":{"type":"integer","minimum":1},
"issue":{"type":"integer","minimum":1,"description":"Issue number in the same repo whose risk/ label classifies this change. Merge happens only for exactly one label, risk/low; no issue, none, several, an unknown value, risk/medium or risk/high means nothing is merged (unclassified is not low)."},
"style":{"type":"string","enum":["merge","squash","rebase"]}, "style":{"type":"string","enum":["merge","squash","rebase"]},
"merge_message_title":{"type":"string"}, "merge_message_title":{"type":"string"},
"merge_message_field":{"type":"string"} "merge_message_field":{"type":"string"},
"ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."}
}, },
"required":["owner","repo","number"] "required":["owner","repo","number"]
}`), }`),
@@ -42,9 +47,11 @@ type prMergeArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Repo string `json:"repo"`
Number int `json:"number"` Number int `json:"number"`
Issue int `json:"issue"`
Style string `json:"style"` Style string `json:"style"`
Title string `json:"merge_message_title"` Title string `json:"merge_message_title"`
Body string `json:"merge_message_field"` Body string `json:"merge_message_field"`
CITimeoutSec int `json:"ci_timeout_seconds"`
} }
func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
@@ -64,13 +71,47 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
style = "merge" style = "merge"
} }
// The gate's inputs. An unreadable PR or branch-protection probe is an error: the gate cannot be evaluated, so
// nothing is merged. An unreadable issue is a tier (unreadable), not an error: it is reported and not merged.
pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Repo, args.Number)
if err != nil {
return nil, fmt.Errorf("read PR #%d: %w", args.Number, err)
}
// Gitea refuses closed and merged PRs itself; the gate does not depend on that.
if pr.State != "open" || pr.Draft {
reason := fmt.Sprintf("the PR is not open (state %q) — nothing to merge", pr.State)
if pr.State == "open" {
reason = "the PR is a draft — not merged"
}
return textOK(map[string]any{"merged": false, "pr_number": args.Number, "reason": reason})
}
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, pr.Base.Ref)
if err != nil {
return nil, fmt.Errorf("branch protection probe: %w", err)
}
runs := pollRuns(ctx, t.c, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
risk := riskTierOf(ctx, t.c, args.Owner, args.Repo, args.Issue)
gate := evaluateMergeGate(pr.Head.Sha, runs, bp, risk)
result := map[string]any{
"merged": false,
"pr_number": args.Number,
"ci_status": gate.ciStatus,
"risk": string(risk),
}
if !gate.merge {
result["reason"] = gate.reason
return textOK(result)
}
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, args.Number, gitea.MergePRArgs{ if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, args.Number, gitea.MergePRArgs{
Do: style, Do: style,
Title: args.Title, Title: args.Title,
Body: args.Body, Body: args.Body,
HeadCommitID: pr.Head.Sha,
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
result["merged"] = true
return textOK(map[string]any{"merged": true}) return textOK(result)
} }
+366 -48
View File
@@ -6,6 +6,8 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
@@ -15,80 +17,396 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPRMergeSuccess(t *testing.T) { // pr_merge is gated exactly like tbd_ship (gitea-mcp#74, #63): it merges only when the head commit's CI is fully
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // green, the base is not review-protected AND the issue named in `issue` carries exactly one risk/ label, risk/low.
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", r.URL.Path) // The fake serves the whole read side the gate needs and records whether (and how) the merge endpoint was hit.
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"})) const (
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`)) greenRuns = `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`
require.NoError(t, err) lowLabels = `[{"id":1,"name":"risk/low"}]`
)
var result map[string]any type prMergeFakeOpts struct {
require.NoError(t, json.Unmarshal(out, &result)) runs string // workflow_runs array for the head commit
assert.Equal(t, true, result["merged"]) issueLabels string // labels array of issue 42
issueStatus int
prStatus int // GET pulls/7
protectionStatus int // 0 → 404 (unprotected); 200 with required approvals when requiredApprovals>0
requiredApprovals int
mergeStatus int
emptyHeadSha bool // the PR reports head.sha == ""
prState string // default "open"
prDraft bool
} }
func TestPRMergeDefaultsToMergeStyle(t *testing.T) { type prMergeFake struct {
var captured []byte *httptest.Server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu sync.Mutex
var err error mergeCalls int
captured, err = io.ReadAll(r.Body) mergeBody []byte
require.NoError(t, err) runsQuery string // raw query of the last workflow-runs request
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "merge", payload["Do"])
} }
func TestPRMergeConflictReturnsError(t *testing.T) { func (f *prMergeFake) merged() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.mergeCalls > 0 }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict) func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
t.Helper()
if o.runs == "" {
o.runs = greenRuns
}
if o.issueLabels == "" {
o.issueLabels = lowLabels
}
if o.issueStatus == 0 {
o.issueStatus = http.StatusOK
}
if o.prStatus == 0 {
o.prStatus = http.StatusOK
}
if o.mergeStatus == 0 {
o.mergeStatus = http.StatusNoContent
}
if o.prState == "" {
o.prState = "open"
}
sha := "abc"
if o.emptyHeadSha {
sha = ""
}
f := &prMergeFake{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/owner/repo/pulls/7":
w.WriteHeader(o.prStatus)
_, _ = w.Write([]byte(`{"number":7,"title":"t","state":"` + o.prState + `","draft":` + boolJSON(o.prDraft) + `,"html_url":"http://x/pulls/7","head":{"ref":"feat","sha":"` + sha + `"},"base":{"ref":"main"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
switch {
case o.protectionStatus != 0:
w.WriteHeader(o.protectionStatus)
_, _ = w.Write([]byte(`{"message":"boom"}`))
case o.requiredApprovals > 0:
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(o.requiredApprovals) + `}`))
default:
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
f.mu.Lock()
f.runsQuery = r.URL.RawQuery
f.mu.Unlock()
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + o.runs + `}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/owner/repo/issues/42":
w.WriteHeader(o.issueStatus)
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":` + o.issueLabels + `}`))
case r.Method == http.MethodPost && p == "/api/v1/repos/owner/repo/pulls/7/merge":
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.mergeCalls++
f.mergeBody = body
f.mu.Unlock()
w.WriteHeader(o.mergeStatus)
_, _ = w.Write([]byte(`{"message":"merge conflict"}`)) _, _ = w.Write([]byte(`{"message":"merge conflict"}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
})) }))
defer srv.Close() t.Cleanup(f.Close)
return f
}
func boolJSON(b bool) string {
if b {
return "true"
}
return "false"
}
func callMerge(t *testing.T, f *prMergeFake, args string) (map[string]any, error) {
t.Helper()
tool := tools.NewPRMerge(gitea.NewClient(f.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(args))
if err != nil {
return nil, err
}
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
return res, nil
}
const mergeArgs = `{"owner":"owner","repo":"repo","number":7,"issue":42}`
func TestPRMergeSuccess(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, true, res["merged"])
assert.Equal(t, "success", res["ci_status"])
assert.Equal(t, "low", res["risk"])
assert.Equal(t, float64(7), res["pr_number"])
assert.True(t, f.merged())
}
func TestPRMergeDefaultsToMergeStyleAndPassesStyleThrough(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
_, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(f.mergeBody, &payload))
assert.Equal(t, "merge", payload["Do"])
f2 := newPRMergeFake(t, prMergeFakeOpts{})
_, err = callMerge(t, f2, `{"owner":"owner","repo":"repo","number":7,"issue":42,"style":"squash"}`)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(f2.mergeBody, &payload))
assert.Equal(t, "squash", payload["Do"])
}
// Gitea 1.27's MergePullRequestOption has merge_title_field and merge_message_field. A field it does not know is
// ignored without an error, so a custom merge title silently did nothing while this tool sent merge_message_title
// (gitea-mcp#77). The tool's own argument keeps its name; the wire field must be Gitea's.
func TestPRMerge_ACustomMergeTitleTravelsAsGiteasMergeTitleField(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
_, err := callMerge(t, f, `{"owner":"owner","repo":"repo","number":7,"issue":42,"merge_message_title":"feat: one line","merge_message_field":"body text"}`)
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(f.mergeBody, &payload))
assert.Equal(t, "feat: one line", payload["merge_title_field"])
assert.Equal(t, "body text", payload["merge_message_field"])
assert.NotContains(t, payload, "merge_message_title", "not a Gitea field: it is ignored without an error")
}
// TOCTOU: the gate read CI for ONE head commit. The merge must be bound to that commit, so a push to the PR branch
// after the check makes Gitea refuse the merge instead of merging code nobody checked.
func TestPRMerge_TheMergeIsBoundToTheCommitTheGateChecked(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
_, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(f.mergeBody, &payload))
assert.Equal(t, "abc", payload["head_commit_id"], "the head sha the CI check used")
}
// A merge conflict is still an error, as before the gate existed.
func TestPRMergeConflictReturnsError(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{mergeStatus: http.StatusConflict})
_, err := callMerge(t, f, mergeArgs)
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict) assert.ErrorIs(t, err, gitea.ErrConflict)
assert.True(t, f.merged(), "the merge was attempted: the gate had passed")
} }
// #45: pr_merge advertises the canonical `number` (was `index`); `index` stays // #45: pr_merge advertises the canonical `number` (was `index`); `index` stays an accepted alias via the shim.
// an accepted alias via the shim.
func TestPRMergeNumberCanonical(t *testing.T) { func TestPRMergeNumberCanonical(t *testing.T) {
sch := string(tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor().InputSchema) sch := string(tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor().InputSchema)
assert.Contains(t, sch, `"number":`, "pr_merge must advertise number") assert.Contains(t, sch, `"number":`, "pr_merge must advertise number")
assert.NotContains(t, sch, `"index":`, "pr_merge must not advertise index") assert.NotContains(t, sch, `"index":`, "pr_merge must not advertise index")
for _, args := range []string{ for _, args := range []string{
`{"owner":"owner","repo":"repo","number":7}`, `{"owner":"owner","repo":"repo","number":7,"issue":42}`,
`{"owner":"owner","repo":"repo","index":7}`, `{"owner":"owner","repo":"repo","index":7,"issue":42}`,
`{"owner":"owner","name":"repo","index":7,"issue":42}`,
} { } {
var gotPath string f := newPRMergeFake(t, prMergeFakeOpts{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { res, err := callMerge(t, f, args)
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(args))
require.NoError(t, err, args) require.NoError(t, err, args)
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", gotPath, args) assert.Equal(t, true, res["merged"], args)
srv.Close()
} }
} }
// gitea-mcp#74: the descriptor tells the caller about the gate.
func TestPRMergeDescriptorAdvertisesTheGate(t *testing.T) {
d := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor()
assert.Contains(t, string(d.InputSchema), `"issue":`)
assert.Contains(t, string(d.InputSchema), `"ci_timeout_seconds":`)
assert.Contains(t, d.Description, "risk/low")
assert.Contains(t, d.Description, "merged:false")
}
func TestPRMergeAllowlistRejects(t *testing.T) { func TestPRMergeAllowlistRejects(t *testing.T) {
tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`))
require.Error(t, err) require.Error(t, err)
} }
// ---- the tier: only exactly one risk/low merges ----
func TestPRMerge_RiskTier_OnlyLowMerges(t *testing.T) {
tests := []struct {
name string
labels string
wantMerged bool
wantInReason string
}{
{"risk/low merges", `[{"id":1,"name":"risk/low"}]`, true, ""},
{"risk/low among other labels merges", `[{"id":2,"name":"type/fix"},{"id":1,"name":"risk/low"}]`, true, ""},
{"risk/medium does not merge", `[{"id":1,"name":"risk/medium"}]`, false, "risk/medium"},
{"risk/high does not merge", `[{"id":1,"name":"risk/high"}]`, false, "risk/high"},
{"no risk label is unclassified, not low", `[{"id":2,"name":"type/fix"}]`, false, "no risk/"},
{"no labels at all", `[]`, false, "no risk/"},
{"two risk labels are ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/high"}]`, false, "more than one"},
{"two identical risk/low labels are still ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/low"}]`, false, "more than one"},
{"an unknown value does not merge", `[{"id":1,"name":"risk/lowish"}]`, false, "unknown"},
{"the value is case-sensitive: risk/Low", `[{"id":1,"name":"risk/Low"}]`, false, "unknown"},
{"trailing space in the value", `[{"id":1,"name":"risk/low "}]`, false, "unknown"},
{"RISK/LOW is not a risk/ label", `[{"id":1,"name":"RISK/LOW"}]`, false, "no risk/"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{issueLabels: tc.labels})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err, "a refusal is a result, not an error")
assert.Equal(t, tc.wantMerged, res["merged"])
assert.Equal(t, tc.wantMerged, f.merged(), "the merge endpoint is called iff the tier allows it")
assert.Equal(t, "success", res["ci_status"], "CI was green: the tier alone decided")
if !tc.wantMerged {
assert.Contains(t, res["reason"], tc.wantInReason)
}
})
}
}
func TestPRMerge_NoIssue_Unclassified_NoMerge(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
res, err := callMerge(t, f, `{"owner":"owner","repo":"repo","number":7}`)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Equal(t, "none", res["risk"])
assert.Contains(t, res["reason"], "issue")
assert.False(t, f.merged(), "an unclassified change must not merge")
}
func TestPRMerge_IssueLookupFails_FailsClosed(t *testing.T) {
for _, status := range []int{http.StatusInternalServerError, http.StatusNotFound, http.StatusForbidden} {
f := newPRMergeFake(t, prMergeFakeOpts{issueStatus: status})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err, "status %d", status)
assert.Equal(t, false, res["merged"], "status %d", status)
assert.Equal(t, "unreadable", res["risk"], "status %d", status)
assert.False(t, f.merged(), "status %d: an unreadable issue must not merge", status)
}
}
// ---- risk/low is necessary, not sufficient: CI and branch protection still gate ----
func TestPRMerge_RiskLow_DoesNotOverrideCI(t *testing.T) {
for name, runs := range map[string]string{
"red": `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`,
"cancelled": `[{"id":1,"status":"completed","conclusion":"cancelled","head_sha":"abc"}]`,
"in_progress": `[{"id":1,"status":"in_progress","conclusion":"","head_sha":"abc"}]`,
"no runs": `[]`,
"mixed": `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"},{"id":2,"status":"queued","conclusion":"","head_sha":"abc"}]`,
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{runs: runs})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"])
assert.False(t, f.merged(), "risk/low must not override CI that is not fully green")
})
}
}
func TestPRMerge_RiskLow_DoesNotOverrideAReviewProtectedBase(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{requiredApprovals: 1})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "review")
assert.False(t, f.merged())
}
// An unreadable PR or an unreadable branch-protection probe is an error, never a merge.
func TestPRMerge_ReadSideFailures_AreErrorsAndNeverMerge(t *testing.T) {
for name, o := range map[string]prMergeFakeOpts{
"PR lookup 500": {prStatus: http.StatusInternalServerError},
"PR lookup 404": {prStatus: http.StatusNotFound},
"protection probe 500": {protectionStatus: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, o)
_, err := callMerge(t, f, mergeArgs)
require.Error(t, err)
assert.False(t, f.merged(), "must not merge when the gate cannot be evaluated")
})
}
}
// The classification comes from the issue the caller names, not from `number`: a PR number that is also an issue
// must not classify itself by accident. (The gate trusts the named issue: documented limit.)
func TestPRMerge_TheTierIsReadFromTheNamedIssueOnly(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{issueLabels: `[{"id":1,"name":"risk/high"}]`})
res, err := callMerge(t, f, `{"owner":"owner","repo":"repo","number":7,"issue":42}`)
require.NoError(t, err)
assert.Equal(t, "high", res["risk"])
assert.False(t, f.merged())
}
// ---- review of PR #76 ----
// An empty head sha used to disable BOTH the CI scoping (the runs query lost its head_sha filter, so the repo's most
// recent runs of any commit were classified) and the merge binding (head_commit_id is omitempty). Fail closed.
func TestPRMerge_AnEmptyHeadShaFailsClosed(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{emptyHeadSha: true})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "head commit")
assert.False(t, f.merged(), "an unknown head commit must never merge")
assert.Empty(t, f.runsQuery, "no unscoped CI query may be made when the head sha is unknown")
}
// CI is read for the PR's head commit only: the query names it.
func TestPRMerge_CIIsScopedToTheHeadCommit(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
_, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Contains(t, f.runsQuery, "head_sha=abc")
}
// Defence in depth: Gitea refuses closed/merged PRs itself, but the gate should not depend on that.
func TestPRMerge_ClosedOrDraftPRsAreNotMerged(t *testing.T) {
for name, o := range map[string]prMergeFakeOpts{
"closed": {prState: "closed"},
"merged": {prState: "merged"},
"draft": {prDraft: true},
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, o)
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"])
assert.False(t, f.merged())
})
}
}
+116
View File
@@ -0,0 +1,116 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// countingServer fails nothing by itself; it records how many requests reached
// it so a test can assert that a rejected call never touched Gitea.
func countingServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var n atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n.Add(1)
w.WriteHeader(http.StatusTeapot)
}))
t.Cleanup(srv.Close)
return srv, &n
}
// #65 (1): encoding/json matches keys case-insensitively and the last one wins,
// so a client-side check reading `branch` could see a different value from the
// one the server acts on. Any two keys equal under strings.EqualFold, and any
// key that differs from a schema field only in case, are rejected before a
// request is made.
func TestParseArgsRejectsCaseVariantKeys(t *testing.T) {
cases := map[string]string{
"issue repro: branch + Branch": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","branch":"feat/y8","Branch":"main"}`,
"exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`,
"case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`,
"nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`,
// PR #66 review: a number the token scanner cannot parse as float64 must
// not make the scan give up and let the duplicate through.
"1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`,
"-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`,
// encoding/json folds like strings.EqualFold: long s (U+017F) == s.
"nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
_, err := tool.Call(context.Background(), json.RawMessage(raw))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
}
}
// #65 (2): `refs/heads/main` as a branch makes Gitea create
// refs/heads/refs/heads/main, after which every API write to main 500s.
// Branch names that can create such refs are rejected before any request.
func TestBranchNameValidationRejectsBeforeRequest(t *testing.T) {
bad := []string{
"refs/heads/main", "refs/tags/v1", "a..b", "feat x", "feat\tx", "feat/",
"/feat", "-feat", ".feat", "feat//x", "feat/.x", "feat.lock", "feat.", "HEAD", "feat@{1}", "",
}
for _, b := range bad {
t.Run("file_write_branch "+b, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
args, _ := json.Marshal(map[string]string{
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
if b == "" {
continue // tbd_ship derives a branch when empty
}
t.Run("tbd_ship "+b, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
args, _ := json.Marshal(map[string]string{
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
}
}
// tbd_ship's base is the branch-protection probe key: `refs/heads/main` would
// read as unprotected, so it is held to the same rule as the head branch.
func TestTBDShipRejectsRefsPrefixedBase(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","base":"refs/heads/main"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
}
func TestBranchNameValidationAcceptsOrdinaryNames(t *testing.T) {
for _, b := range []string{"main", "feat/y8", "tbd/fix-abc-1234abcd", "hold/mail-chat-infra-21", "v1.2_x"} {
t.Run(b, func(t *testing.T) {
assert.NoError(t, tools.ValidateBranchName(b))
})
}
}
+126 -16
View File
@@ -37,7 +37,7 @@ const (
func (t *TBDShip) Descriptor() registry.ToolDescriptor { func (t *TBDShip) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "tbd_ship", Name: "tbd_ship",
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, reason, branch}.", Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green AND the named issue carries exactly one risk/ label, risk/low. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when no issue is named or its risk/ label is missing, ambiguous, unknown, risk/medium or risk/high, when the issue cannot be read, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, risk, reason, branch}.",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
@@ -50,6 +50,7 @@ func (t *TBDShip) Descriptor() registry.ToolDescriptor {
"branch":{"type":"string","description":"Short-lived branch name. Default derived: tbd/<slug>-<hash>."}, "branch":{"type":"string","description":"Short-lived branch name. Default derived: tbd/<slug>-<hash>."},
"pr_title":{"type":"string","description":"PR title. Default: the commit message."}, "pr_title":{"type":"string","description":"PR title. Default: the commit message."},
"pr_body":{"type":"string"}, "pr_body":{"type":"string"},
"issue":{"type":"integer","minimum":1,"description":"Issue number in the same repo whose risk/ label classifies this change. Auto-merge happens only for exactly one label, risk/low; no issue, none, several, an unknown value, risk/medium or risk/high means PR only, never merged (unclassified is not low)."},
"delete_branch":{"type":"boolean","description":"Delete the branch after a successful merge. Default true."}, "delete_branch":{"type":"boolean","description":"Delete the branch after a successful merge. Default true."},
"ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."} "ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."}
}, },
@@ -68,10 +69,76 @@ type tbdShipArgs struct {
Branch string `json:"branch"` Branch string `json:"branch"`
PRTitle string `json:"pr_title"` PRTitle string `json:"pr_title"`
PRBody string `json:"pr_body"` PRBody string `json:"pr_body"`
Issue int `json:"issue"`
DeleteBranch *bool `json:"delete_branch"` DeleteBranch *bool `json:"delete_branch"`
CITimeoutSec int `json:"ci_timeout_seconds"` CITimeoutSec int `json:"ci_timeout_seconds"`
} }
// riskTier is the change's classification under the lane model (local-dev .context/AGENT.md rule 5): only a change
// whose issue carries exactly one risk/ label, risk/low, may merge on green; everything else is reviewed by a human
// (gitea-mcp#63). The zero value is deliberately NOT riskLow: a forgotten field must never mean "low".
type riskTier string
const (
riskLow riskTier = "low"
riskMedium riskTier = "medium"
riskHigh riskTier = "high"
riskNone riskTier = "none" // no issue named, or the issue has no risk/ label: unclassified is not low
riskAmbiguous riskTier = "ambiguous" // more than one risk/ label
riskUnknown riskTier = "unknown" // risk/<something else>
riskUnreadable riskTier = "unreadable" // the issue lookup failed
)
// classifyRisk reads the tier from an issue's labels. Exact, case-sensitive prefix "risk/"; exactly one such label
// is required, so two labels (even identical ones) are ambiguous rather than "the lower one wins".
func classifyRisk(labels []gitea.Label) riskTier {
var found []string
for _, l := range labels {
if strings.HasPrefix(l.Name, "risk/") {
found = append(found, strings.TrimPrefix(l.Name, "risk/"))
}
}
switch len(found) {
case 0:
return riskNone
case 1:
default:
return riskAmbiguous
}
switch found[0] {
case "low":
return riskLow
case "medium":
return riskMedium
case "high":
return riskHigh
default:
return riskUnknown
}
}
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR left open, not merged"
// riskBlockReason explains why a tier other than riskLow blocks the merge.
func riskBlockReason(r riskTier) string {
switch r {
case riskMedium, riskHigh:
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR left open, not merged", r)
case riskAmbiguous:
return "the issue carries more than one risk/ label — ambiguous classification; PR left open, not merged"
case riskUnknown:
return "the issue carries an unknown risk/ value — PR left open, not merged"
case riskUnreadable:
return "could not read the issue's labels — fail-closed; PR left open, not merged"
case riskLow:
return "" // never asked: only a non-low tier needs explaining
case riskNone:
return unclassifiedReason
default: // the zero value and any tier added later: unclassified is not low
return unclassifiedReason
}
}
// shipGate is the decision produced by evaluateShipGate. // shipGate is the decision produced by evaluateShipGate.
type shipGate struct { type shipGate struct {
merge bool merge bool
@@ -101,29 +168,46 @@ func classifyCI(runs []gitea.WorkflowRun) string {
} }
// evaluateShipGate is the load-bearing safety of tbd_ship. It returns merge=true // evaluateShipGate is the load-bearing safety of tbd_ship. It returns merge=true
// only when CI is fully green AND the base branch is not review-protected. Every // only when CI is fully green AND the base branch is not review-protected AND the
// other state fails closed to PR-only with an explanatory reason (#40). // change is classified risk/low. Every other state fails closed to PR-only with an
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection) shipGate { // explanatory reason (#40, gitea-mcp#63).
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
ci := classifyCI(runs) ci := classifyCI(runs)
// Review-protected base can't be auto-merged regardless of CI. // Review-protected base can't be auto-merged regardless of CI.
if bp != nil && bp.Protected && bp.RequiredApprovals > 0 { if bp != nil && bp.Protected && bp.RequiredApprovals > 0 {
return shipGate{false, ci, fmt.Sprintf( return shipGate{false, ci, fmt.Sprintf(
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)} "base branch requires %d approving review(s) — auto-merge disabled; PR left open for manual merge", bp.RequiredApprovals)}
}
// Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI".
if risk != riskLow {
return shipGate{false, ci, riskBlockReason(risk)}
} }
switch ci { switch ci {
case "success": case "success":
return shipGate{true, ci, ""} return shipGate{true, ci, ""}
case "pending": case "pending":
return shipGate{false, ci, "CI is still running for the head commit — PR opened; re-invoke once checks complete, or merge manually"} return shipGate{false, ci, "CI is still running for the head commit — PR left open; re-invoke once checks complete, or merge manually"}
case "failed": case "failed":
return shipGate{false, ci, "CI failed for the head commit — PR opened, not merged"} return shipGate{false, ci, "CI failed for the head commit — PR left open, not merged"}
default: // none default: // none
return shipGate{false, ci, "no CI runs found for the head commit — fail-closed: a change with no CI gate is never auto-merged to trunk; PR opened for manual merge"} return shipGate{false, ci, "no CI runs found for the head commit — fail-closed: a change with no CI gate is never auto-merged to trunk; PR left open for manual merge"}
} }
} }
// evaluateMergeGate is what both merge tools call. It adds one precondition to evaluateShipGate: the PR's head commit
// must be known. CI is read for that commit and the merge is bound to it (head_commit_id); with an empty sha the runs
// query would lose its filter (the repo's most recent runs of ANY commit would be classified) and the binding would be
// dropped, so an unknown head commit never merges.
func evaluateMergeGate(headSHA string, runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
if headSHA == "" {
return shipGate{false, "none", "the PR's head commit is unknown — fail-closed (CI cannot be scoped and the merge cannot be bound to it); PR left open, not merged"}
}
return evaluateShipGate(runs, bp, risk)
}
var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`) var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
// deriveBranch builds a deterministic short-lived branch name from the change, // deriveBranch builds a deterministic short-lived branch name from the change,
@@ -160,6 +244,13 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if branch == "" { if branch == "" {
branch = deriveBranch(args.Message, args.Path, args.Content) branch = deriveBranch(args.Message, args.Path, args.Content)
} }
// Both names reach a ref: base is also the protection-probe key, so a
// `refs/heads/main` base would read as unprotected (#65).
for _, b := range []string{base, branch} {
if err := ValidateBranchName(b); err != nil {
return nil, err
}
}
deleteBranch := true deleteBranch := true
if args.DeleteBranch != nil { if args.DeleteBranch != nil {
deleteBranch = *args.DeleteBranch deleteBranch = *args.DeleteBranch
@@ -222,8 +313,9 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
} }
// CI gate on the PR head commit. // CI gate on the PR head commit.
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec) runs := pollRuns(ctx, t.c, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
gate := evaluateShipGate(runs, bp) risk := riskTierOf(ctx, t.c, args.Owner, args.Repo, args.Issue)
gate := evaluateMergeGate(pr.Head.Sha, runs, bp, risk)
result := map[string]any{ result := map[string]any{
"merged": false, "merged": false,
@@ -231,6 +323,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
"pr_url": pr.HTMLURL, "pr_url": pr.HTMLURL,
"branch": branch, "branch": branch,
"ci_status": gate.ciStatus, "ci_status": gate.ciStatus,
"risk": string(risk),
} }
if !gate.merge { if !gate.merge {
result["reason"] = gate.reason result["reason"] = gate.reason
@@ -238,9 +331,9 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
} }
// Green + unprotected → squash-merge, then delete the short-lived branch. // Green + unprotected → squash-merge, then delete the short-lived branch.
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash"}); err != nil { if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash", HeadCommitID: pr.Head.Sha}); err != nil {
if errors.Is(err, gitea.ErrConflict) { if errors.Is(err, gitea.ErrConflict) {
result["reason"] = "base moved and the merge is not clean — PR opened, not merged; resolve the conflict and merge manually" result["reason"] = "base moved and the merge is not clean — PR left open, not merged; resolve the conflict and merge manually"
return textOK(result) return textOK(result)
} }
return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err) return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err)
@@ -254,10 +347,27 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
return textOK(result) return textOK(result)
} }
// riskTierOf classifies the change from the named issue. No issue, or an issue that cannot be read, never yields
// riskLow. The link between the PR and the issue is the caller's claim: the gate enforces the tier of the issue it
// is given, it cannot prove that issue is the one the change belongs to (stated limit, gitea-mcp#63).
func riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue int) riskTier {
if issue <= 0 {
return riskNone
}
iss, err := c.GetIssue(ctx, owner, repo, issue)
if err != nil || iss == nil {
return riskUnreadable
}
return classifyRisk(iss.Labels)
}
// pollRuns lists the head commit's workflow runs, polling up to timeoutSec for // pollRuns lists the head commit's workflow runs, polling up to timeoutSec for
// them to reach a terminal state. timeoutSec == 0 is a single snapshot (no // them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
// sleep). A listing error yields no runs → the gate fails closed. // sleep). A listing error yields no runs → the gate fails closed.
func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun { func pollRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun {
if headSHA == "" {
return nil // never query without the commit filter: Gitea would return the repo's latest runs of any commit
}
if timeoutSec < 0 { if timeoutSec < 0 {
timeoutSec = 0 timeoutSec = 0
} }
@@ -266,7 +376,7 @@ func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, tim
} }
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for { for {
runs := t.listRuns(ctx, owner, repo, headSHA) runs := listRuns(ctx, c, owner, repo, headSHA)
switch classifyCI(runs) { switch classifyCI(runs) {
case "success", "failed": case "success", "failed":
return runs // terminal — no point waiting return runs // terminal — no point waiting
@@ -297,8 +407,8 @@ func (t *TBDShip) findOpenPR(ctx context.Context, owner, repo, branch string) (*
return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound) return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound)
} }
func (t *TBDShip) listRuns(ctx context.Context, owner, repo, headSHA string) []gitea.WorkflowRun { func listRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string) []gitea.WorkflowRun {
resp, err := t.c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50}) resp, err := c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
if err != nil || resp == nil { if err != nil || resp == nil {
return nil return nil
} }
+95 -10
View File
@@ -1,6 +1,11 @@
package tools package tools
import ( import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -20,22 +25,31 @@ func TestEvaluateShipGate(t *testing.T) {
name string name string
runs []gitea.WorkflowRun runs []gitea.WorkflowRun
bp *gitea.BranchProtection bp *gitea.BranchProtection
risk riskTier
wantMerge bool wantMerge bool
wantCI string wantCI string
}{ }{
{"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, true, "success"}, {"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskLow, true, "success"},
{"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, false, "pending"}, {"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, riskLow, false, "pending"},
{"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, false, "pending"}, {"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, riskLow, false, "pending"},
{"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, false, "failed"}, {"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, riskLow, false, "failed"},
{"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, false, "failed"}, {"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, riskLow, false, "failed"},
{"no runs → no merge (no CI gate)", nil, open, false, "none"}, {"no runs → no merge (no CI gate)", nil, open, riskLow, false, "none"},
{"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, false, "pending"}, {"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, riskLow, false, "pending"},
{"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, false, "success"}, {"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, riskLow, false, "success"},
{"no runs + protected → no merge", nil, protected, false, "none"}, {"no runs + protected → no merge", nil, protected, riskLow, false, "none"},
// gitea-mcp#63: the tier is an input. Green CI never overrides it; unclassified is not low.
{"green + risk/medium → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskMedium, false, "success"},
{"green + risk/high → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskHigh, false, "success"},
{"green + no risk label → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskNone, false, "success"},
{"green + ambiguous labels → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskAmbiguous, false, "success"},
{"green + unknown value → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnknown, false, "success"},
{"green + unreadable issue → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnreadable, false, "success"},
{"green + the zero tier → no merge (a forgotten field must not mean low)", []gitea.WorkflowRun{run("completed", "success")}, open, riskTier(""), false, "success"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
g := evaluateShipGate(tc.runs, tc.bp) g := evaluateShipGate(tc.runs, tc.bp, tc.risk)
if g.merge != tc.wantMerge { if g.merge != tc.wantMerge {
t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason) t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason)
} }
@@ -51,3 +65,74 @@ func TestEvaluateShipGate(t *testing.T) {
}) })
} }
} }
func TestClassifyRisk(t *testing.T) {
l := func(names ...string) []gitea.Label {
out := make([]gitea.Label, len(names))
for i, n := range names {
out[i] = gitea.Label{Name: n}
}
return out
}
tests := []struct {
name string
labels []gitea.Label
want riskTier
}{
{"low", l("risk/low"), riskLow},
{"medium", l("risk/medium"), riskMedium},
{"high", l("risk/high"), riskHigh},
{"low among others", l("type/fix", "risk/low", "size/s"), riskLow},
{"none", l("type/fix"), riskNone},
{"no labels", nil, riskNone},
{"two different", l("risk/low", "risk/high"), riskAmbiguous},
{"two identical", l("risk/low", "risk/low"), riskAmbiguous},
{"unknown value", l("risk/extreme"), riskUnknown},
{"empty value", l("risk/"), riskUnknown},
{"wrong case", l("Risk/Low"), riskNone},
{"value case matters: risk/Low is unknown, not low", l("risk/Low"), riskUnknown},
{"trailing space in the value is unknown, not low", l("risk/low "), riskUnknown},
{"leading space in the value is unknown, not low", l("risk/ low"), riskUnknown},
{"prefix only inside a name", l("not-risk/low"), riskNone},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := classifyRisk(tc.labels); got != tc.want {
t.Errorf("classifyRisk(%v) = %q, want %q", tc.labels, got, tc.want)
}
})
}
}
// The merge gate needs a head commit to scope CI to and to bind the merge to: without one nothing merges.
func TestEvaluateMergeGate_UnknownHeadCommitNeverMerges(t *testing.T) {
green := []gitea.WorkflowRun{{Status: "completed", Conclusion: "success"}}
g := evaluateMergeGate("", green, &gitea.BranchProtection{}, riskLow)
if g.merge {
t.Fatalf("merge = true for an empty head sha (reason %q)", g.reason)
}
if !strings.Contains(g.reason, "head commit") {
t.Errorf("reason %q should name the head commit", g.reason)
}
if ok := evaluateMergeGate("abc", green, &gitea.BranchProtection{}, riskLow); !ok.merge {
t.Errorf("a known head sha with green CI and risk/low must still merge, got %q", ok.reason)
}
}
func TestPollRuns_NoShaMakesNoQuery(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"id":1,"status":"completed","conclusion":"success"}]}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
if runs := pollRuns(context.Background(), c, "o", "r", "", 0); len(runs) != 0 || requests.Load() != 0 {
t.Errorf("an empty sha must make no request and return no runs, got %d runs after %d requests", len(runs), requests.Load())
}
// positive control: with a sha the same server is asked, so the zero above means something
if runs := pollRuns(context.Background(), c, "o", "r", "abc", 0); len(runs) != 1 || requests.Load() != 1 {
t.Errorf("control: with a sha expected 1 run after 1 request, got %d runs after %d requests", len(runs), requests.Load())
}
}
+154 -7
View File
@@ -3,6 +3,7 @@ package tools_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -26,11 +27,21 @@ func shipFake(t *testing.T, runsJSON string, merged, deleted *atomic.Bool) *http
// shipFakeOpts adds protection (requiredApprovals>0 → protected) and a merge // shipFakeOpts adds protection (requiredApprovals>0 → protected) and a merge
// status code, so tests can drive the review-protected and conflict paths. // status code, so tests can drive the review-protected and conflict paths.
func shipFakeOpts(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, merged, deleted *atomic.Bool) *httptest.Server { func shipFakeOpts(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, merged, deleted *atomic.Bool) *httptest.Server {
t.Helper()
return shipFakeFull(t, runsJSON, requiredApprovals, mergeStatus, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, merged, deleted)
}
// shipFakeFull also serves the issue whose risk/ label classifies the change (gitea-mcp#63): issueLabels is the
// labels JSON array, issueStatus the HTTP status of the issue lookup.
func shipFakeFull(t *testing.T, runsJSON string, requiredApprovals, mergeStatus int, issueLabels string, issueStatus int, merged, deleted *atomic.Bool) *httptest.Server {
t.Helper() t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := r.URL.Path
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch { switch {
case r.Method == http.MethodGet && strings.Contains(p, "/issues/42"):
w.WriteHeader(issueStatus)
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":` + issueLabels + `}`))
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"): case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
if requiredApprovals > 0 { if requiredApprovals > 0 {
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(requiredApprovals) + `}`)) _, _ = w.Write([]byte(`{"required_approvals":` + itoa(requiredApprovals) + `}`))
@@ -49,10 +60,12 @@ func shipFakeOpts(t *testing.T, runsJSON string, requiredApprovals, mergeStatus
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"s"},"commit":{"sha":"c"}}`)) _, _ = w.Write([]byte(`{"content":{"path":"x","sha":"s"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls"): case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls"):
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"number":7,"title":"t","html_url":"http://x/pulls/7","state":"open","head":{"ref":"tbd/x","sha":"abc"},"base":{"ref":"main"}}`)) _, _ = w.Write([]byte(`{"number":7,"title":"t","html_url":"http://x/pulls/7","state":"open","head":{"ref":"tbd/x","sha":"` + shipPRHeadSha + `"},"base":{"ref":"main"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"): case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + runsJSON + `}`)) _, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + runsJSON + `}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls/7/merge"): case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls/7/merge"):
body, _ := io.ReadAll(r.Body)
shipLastMergeBody.Store(string(body))
merged.Store(true) merged.Store(true)
w.WriteHeader(mergeStatus) w.WriteHeader(mergeStatus)
_, _ = w.Write([]byte(`{}`)) _, _ = w.Write([]byte(`{}`))
@@ -83,7 +96,7 @@ func TestTBDShip_GreenCI_Merges(t *testing.T) {
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, &merged, &deleted) srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, &merged, &deleted)
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
assert.Equal(t, true, res["merged"]) assert.Equal(t, true, res["merged"])
assert.Equal(t, "success", res["ci_status"]) assert.Equal(t, "success", res["ci_status"])
@@ -98,7 +111,7 @@ func TestTBDShip_NoCI_FailsClosed(t *testing.T) {
srv := shipFake(t, `[]`, &merged, &deleted) srv := shipFake(t, `[]`, &merged, &deleted)
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
assert.Equal(t, false, res["merged"]) assert.Equal(t, false, res["merged"])
assert.Equal(t, "none", res["ci_status"]) assert.Equal(t, "none", res["ci_status"])
@@ -113,7 +126,7 @@ func TestTBDShip_RedCI_FailsClosed(t *testing.T) {
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, &merged, &deleted) srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, &merged, &deleted)
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
assert.Equal(t, false, res["merged"]) assert.Equal(t, false, res["merged"])
assert.Equal(t, "failed", res["ci_status"]) assert.Equal(t, "failed", res["ci_status"])
@@ -128,7 +141,7 @@ func TestTBDShip_ReviewProtected_FailsClosed(t *testing.T) {
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 1, http.StatusOK, &merged, &deleted) srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 1, http.StatusOK, &merged, &deleted)
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
assert.Equal(t, false, res["merged"]) assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"]) assert.NotEmpty(t, res["reason"])
@@ -142,7 +155,7 @@ func TestTBDShip_MergeConflict_FailsClosed(t *testing.T) {
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 0, http.StatusConflict, &merged, &deleted) srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 0, http.StatusConflict, &merged, &deleted)
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`)
assert.Equal(t, false, res["merged"]) assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"]) assert.NotEmpty(t, res["reason"])
@@ -160,6 +173,8 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
p := r.URL.Path p := r.URL.Path
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch { switch {
case r.Method == http.MethodGet && strings.Contains(p, "/issues/42"):
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":[{"id":1,"name":"risk/low"}]}`))
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"): case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`)) _, _ = w.Write([]byte(`{"message":"not found"}`))
@@ -196,7 +211,7 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","branch":"`+branch+`"}`) res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42,"branch":"`+branch+`"}`)
assert.Equal(t, true, res["merged"], "resume must merge when CI is green") assert.Equal(t, true, res["merged"], "resume must merge when CI is green")
assert.Equal(t, float64(7), res["pr_number"], "must reuse the existing PR #7") assert.Equal(t, float64(7), res["pr_number"], "must reuse the existing PR #7")
@@ -204,6 +219,138 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
assert.True(t, merged.Load()) assert.True(t, merged.Load())
} }
// shipPRHeadSha is the head sha the fake reports for the PR it opens (tests may blank it, then restore it).
var shipPRHeadSha = "abc"
// shipLastMergeBody is the body of the most recent merge request the fake received.
var shipLastMergeBody atomic.Value
// An empty head sha used to unscope the CI query and drop the merge binding (review of PR #76). Fail closed.
func TestTBDShip_AnEmptyHeadShaFailsClosed(t *testing.T) {
shipPRHeadSha = ""
t.Cleanup(func() { shipPRHeadSha = "abc" })
var merged, deleted atomic.Bool
srv := shipFake(t, shipGreen, &merged, &deleted)
defer srv.Close()
res := callShip(t, srv.URL, shipArgs)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "head commit")
assert.False(t, merged.Load(), "an unknown head commit must never merge")
}
// TOCTOU: the merge is bound to the head commit whose CI the gate evaluated (gitea-mcp#74).
func TestTBDShip_TheMergeIsBoundToTheCommitTheGateChecked(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFake(t, shipGreen, &merged, &deleted)
defer srv.Close()
res := callShip(t, srv.URL, shipArgs)
require.Equal(t, true, res["merged"])
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(shipLastMergeBody.Load().(string)), &payload))
assert.Equal(t, "abc", payload["head_commit_id"], "the head sha the CI check used")
}
// ---- gitea-mcp#63: the risk tier is an input to the gate. Only exactly one risk/low label merges. ----
const (
shipGreen = `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`
shipArgs = `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","issue":42}`
)
func TestTBDShip_RiskTier_OnlyLowMerges(t *testing.T) {
tests := []struct {
name string
labels string
wantMerged bool
wantInReason string
}{
{"risk/low merges", `[{"id":1,"name":"risk/low"}]`, true, ""},
{"risk/low among other labels merges", `[{"id":2,"name":"type/fix"},{"id":1,"name":"risk/low"},{"id":3,"name":"size/s"}]`, true, ""},
{"risk/medium does not merge", `[{"id":1,"name":"risk/medium"}]`, false, "risk/medium"},
{"risk/high does not merge", `[{"id":1,"name":"risk/high"}]`, false, "risk/high"},
{"no risk label is unclassified, not low", `[{"id":2,"name":"type/fix"}]`, false, "no risk/"},
{"no labels at all", `[]`, false, "no risk/"},
{"two risk labels are ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/high"}]`, false, "more than one"},
{"two identical risk/low labels are still ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/low"}]`, false, "more than one"},
{"an unknown risk value does not merge", `[{"id":1,"name":"risk/lowish"}]`, false, "unknown"},
{"an empty risk value does not merge", `[{"id":1,"name":"risk/"}]`, false, "unknown"},
{"labels are case-sensitive: RISK/LOW is not risk/low", `[{"id":1,"name":"RISK/LOW"}]`, false, "no risk/"},
{"a label that merely contains risk/low does not count", `[{"id":1,"name":"not-risk/low"}]`, false, "no risk/"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, tc.labels, http.StatusOK, &merged, &deleted)
defer srv.Close()
res := callShip(t, srv.URL, shipArgs)
assert.Equal(t, tc.wantMerged, res["merged"])
assert.Equal(t, tc.wantMerged, merged.Load(), "the merge endpoint is called iff the tier allows it")
assert.Equal(t, "success", res["ci_status"], "CI was green: the tier alone decided")
assert.Equal(t, float64(7), res["pr_number"], "the PR is opened either way")
if !tc.wantMerged {
assert.Contains(t, res["reason"], tc.wantInReason)
}
})
}
}
// No issue named → nothing classifies the change → no merge, even on green CI.
func TestTBDShip_NoIssue_Unclassified_NoMerge(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFake(t, shipGreen, &merged, &deleted)
defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x"}`)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "issue")
assert.False(t, merged.Load(), "an unclassified change must not merge")
assert.Equal(t, float64(7), res["pr_number"])
}
// The issue lookup failing is not a reason to merge: fail closed, PR left open.
func TestTBDShip_IssueLookupFails_FailsClosed(t *testing.T) {
for _, status := range []int{http.StatusInternalServerError, http.StatusNotFound, http.StatusForbidden} {
var merged, deleted atomic.Bool
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, status, &merged, &deleted)
res := callShip(t, srv.URL, shipArgs)
srv.Close()
assert.Equal(t, false, res["merged"], "status %d", status)
assert.NotEmpty(t, res["reason"], "status %d", status)
assert.False(t, merged.Load(), "status %d: an unreadable issue must not merge", status)
}
}
// risk/low is necessary, not sufficient: red CI still blocks it, and a review-protected base still blocks it.
func TestTBDShip_RiskLow_DoesNotOverrideTheOtherGates(t *testing.T) {
var merged, deleted atomic.Bool
red := shipFakeFull(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, 0, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, &merged, &deleted)
defer red.Close()
assert.Equal(t, false, callShip(t, red.URL, shipArgs)["merged"])
assert.False(t, merged.Load())
prot := shipFakeFull(t, shipGreen, 1, http.StatusOK, `[{"id":1,"name":"risk/low"}]`, http.StatusOK, &merged, &deleted)
defer prot.Close()
assert.Equal(t, false, callShip(t, prot.URL, shipArgs)["merged"])
assert.False(t, merged.Load())
}
// The decision is visible to the caller.
func TestTBDShip_ReportsTheRiskTier(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFakeFull(t, shipGreen, 0, http.StatusOK, `[{"id":1,"name":"risk/medium"}]`, http.StatusOK, &merged, &deleted)
defer srv.Close()
assert.Equal(t, "medium", callShip(t, srv.URL, shipArgs)["risk"])
}
func TestTBDShip_AllowlistRejects(t *testing.T) { func TestTBDShip_AllowlistRejects(t *testing.T) {
tool := tools.NewTBDShip(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) tool := tools.NewTBDShip(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","path":"p","content":"c","message":"m"}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","path":"p","content":"c","message":"m"}`))
+147 -1
View File
@@ -1,10 +1,14 @@
package tools package tools
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"strings" "strings"
"unicode"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "git.d-ma.be/mathias/gitea-mcp/internal/registry"
@@ -22,12 +26,154 @@ func parseArgs(raw json.RawMessage, dst any) error {
if err := json.Unmarshal([]byte("{}"), dst); err != nil { if err := json.Unmarshal([]byte("{}"), dst); err != nil {
return err return err
} }
} else if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil { } else {
// Reject ambiguity BEFORE alias normalisation: re-marshalling through a
// map would otherwise silently collapse exact duplicates (#65).
if err := rejectAmbiguousKeys(raw, dst); err != nil {
return err return err
} }
if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
return err
}
}
return validateRequiredIdentifiers(dst) return validateRequiredIdentifiers(dst)
} }
// rejectAmbiguousKeys refuses input whose meaning depends on encoding/json's
// case-insensitive, last-key-wins matching (#65). A client-side check that reads
// `branch` must see the value the server acts on, so: no object at any depth may
// carry two keys equal under strings.EqualFold (exact duplicates included), and
// no top-level key may differ from a field of dst only in case.
func rejectAmbiguousKeys(raw json.RawMessage, dst any) error {
dec := json.NewDecoder(bytes.NewReader(raw))
// UseNumber: without it Token() fails on a number outside float64 range,
// which json.Unmarshal ignores in an unknown key — a decoy that would end
// the scan before the duplicate (PR #66 review).
dec.UseNumber()
if err := scanJSONValue(dec); err != nil {
if errors.Is(err, gitea.ErrValidation) {
return err
}
// Fail closed: input the scanner cannot read is input it cannot vouch for.
return fmt.Errorf("arguments are not valid JSON: %v: %w", err, gitea.ErrValidation)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return nil // not an object; nothing to compare against the schema
}
fields := jsonFieldNames(dst)
for k := range top {
for _, f := range fields {
if k != f && strings.EqualFold(k, f) {
return fmt.Errorf("argument %q does not match field %q exactly (keys are case-sensitive): %w", k, f, gitea.ErrValidation)
}
}
}
return nil
}
// scanJSONValue consumes one JSON value from dec, failing with ErrValidation if
// any object within it has two keys that are equal under strings.EqualFold.
func scanJSONValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
d, ok := tok.(json.Delim)
if !ok {
return nil
}
switch d {
case '{':
seen := map[string]string{}
for dec.More() {
kt, err := dec.Token()
if err != nil {
return err
}
k, _ := kt.(string)
fk := foldKey(k)
if prev, dup := seen[fk]; dup {
return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation)
}
seen[fk] = k
if err := scanJSONValue(dec); err != nil {
return err
}
}
case '[':
for dec.More() {
if err := scanJSONValue(dec); err != nil {
return err
}
}
}
_, err = dec.Token() // closing delimiter
return err
}
// foldKey maps every rune to the smallest member of its unicode.SimpleFold
// orbit, so foldKey(a) == foldKey(b) exactly when strings.EqualFold(a, b) —
// the folding encoding/json uses (long s == s, Kelvin sign == k). A map keyed
// on it keeps the duplicate scan linear (PR #66 review: a pairwise EqualFold
// loop cost 7s of CPU on ~90k keys).
func foldKey(s string) string {
var b strings.Builder
for _, r := range s {
min := r
for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
if f < min {
min = f
}
}
b.WriteRune(min)
}
return b.String()
}
// jsonFieldNames returns the json tag names of dst's top-level struct fields.
func jsonFieldNames(dst any) []string {
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
return nil
}
t := v.Elem().Type()
names := make([]string, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
if name == "" || name == "-" {
name = t.Field(i).Name
}
names = append(names, name)
}
return names
}
// branchNameRe is the shape the skills mcp-destructive-guard hook enforces.
var branchNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
// ValidateBranchName rejects branch names that Gitea would turn into a ref
// other than refs/heads/<name> as the caller meant it. `refs/heads/main` in
// particular creates refs/heads/refs/heads/main, after which every API write to
// main fails with "matches more than one" until the ref is deleted by hand
// (#65). Beyond the shape regexp it applies git's own check-ref-format rules
// that the regexp admits.
func ValidateBranchName(b string) error {
bad := !branchNameRe.MatchString(b) ||
strings.HasPrefix(b, "refs/") ||
b == "HEAD" ||
strings.Contains(b, "..") ||
strings.Contains(b, "//") ||
strings.Contains(b, "/.") ||
strings.HasSuffix(b, "/") ||
strings.HasSuffix(b, ".") ||
strings.HasSuffix(b, ".lock")
if bad {
return fmt.Errorf("invalid branch name %q (want a short name like feat/x, no refs/ prefix): %w", b, gitea.ErrValidation)
}
return nil
}
// validateRequiredIdentifiers rejects an empty repo identifier at the tool layer // validateRequiredIdentifiers rejects an empty repo identifier at the tool layer
// before it reaches the client, where it would build a trailing-empty path // before it reaches the client, where it would build a trailing-empty path
// segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and // segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and
+1 -1
View File
@@ -58,7 +58,7 @@ if [ "$FORCE" = false ]; then
while IFS= read -r _match; do while IFS= read -r _match; do
_lineno="${_match%%:*}" _lineno="${_match%%:*}"
_content="${_match#*:}" _content="${_match#*:}"
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1) _token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2 echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
done <<< "$_placeholders" done <<< "$_placeholders"
echo "" >&2 echo "" >&2