Compare commits

...
15 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 51b823ae79 fix(create_project): idempotent rename write completes a stray write+delete split (#53)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
The rename path (write new path, then delete old path) is two separate,
non-atomic API calls. If a prior partial run's write succeeded but the paired
delete failed — plausible under the same infra#179 flakiness resume (#50)
exists to work around — a resume would recompute the identical rename and
blind-create (no sha) at a path that already exists, hitting a conflict and
getting stuck needing another resume cycle just to re-report the same thing.

renameEntry now reads the new path first: if it already holds the correct
content (prior write succeeded), the write is skipped and only the
outstanding delete of the old path runs; if the new path exists but differs,
it's updated with the fetched sha instead of blind-created; if the old path
is already gone by delete time, that's treated as done, not a failure.
Mirrors injectDispatchAllow's (#51) read-before-write idempotency pattern.

Test: TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly — a stray
old path plus an already-correct new path resolves to a single delete, zero
redundant writes, no partial_failure. All prior create_project tests
unaffected (backward compatible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:09:26 +02:00
mathiasandClaude Opus 4.8 ac337955e7 fix(issue_label): schema wrongly required labels, blocking label_ids-only callers (#52 review finding)
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
Independent adversarial review of #52 (v0.8.0) caught a schema/implementation
mismatch: the advertised InputSchema marked "labels" as required, but Call
already treated labels/label_ids as either-or. An MCP client that validates
arguments against the advertised schema before dispatch would reject a
label_ids-only call as invalid even though the code was written to serve it —
and that path had zero test coverage either way.

Dropped "labels" from the required array (owner/repo/number remain required);
runtime validation already correctly requires at least one of labels/label_ids.
Added TestIssueLabelAppliesByIDOnly (asserts ListLabels is never called when
IDs are already known) and a schema-lock test for the fixed contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:57:05 +02:00
mathiasandClaude Opus 4.8 b288462a1c feat(create_project): resume=true makes substitution durable against infra#179 (#50, #51)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
parallax#1 was the real-world evidence: infra#179's branch-writability stall
(>40s observed) blew past the tool's 5s budget, substitution ran zero files,
and the repo shipped genuinely broken (go.mod still read `module
__MODULE_PATH__`) — recoverable only via a manual clone/sed/git-mv/push.

Adds `resume: bool`. When true, skips template lookup and repo generation
entirely — the destination must already exist — and jumps straight to the
tree-walk substitution. This is safe to re-invoke repeatedly because
substituteEntry already compares against the branch's CURRENT state on every
call (GetTree is re-fetched fresh each time): a file already fixed in a prior
partial pass shows up already-correct or already-renamed and is a no-op. So the
actual blocker to resumability was purely the "destination must NOT exist"
guard on the create path — flipped it for resume, no change needed to the
substitution logic itself.

Consequences of resume existing:
- infra179FinalizeMessage (#46) now points at the concrete recovery — "call
  this tool again with resume=true" — instead of manual clone/sed guidance.
- "no placeholders substituted" only loud-fails on a FRESH create; on resume,
  finding nothing left to do is the expected steady state (success).
- dispatch_allow injection (#43) is now idempotent (read-before-write, update
  with sha if present-but-different, skip if already correct) so a resumed
  call with dispatch_allow=true doesn't error re-creating a path that already
  exists (#51's root cause).
- #51's other ask: dispatch_allow failures now land in their own
  dispatch_allow_failure field, never conflated with substitution's
  partial_failure — the two can independently succeed/fail.

Tests: resume with no destination (error, names "nothing to resume"), resume
continuing a partial substitution (asserts /generate is never re-called, only
the still-wrong file is rewritten), resume when already fully done (success,
not the fresh-create loud-fail), dispatch_allow idempotent re-injection
(two-phase test capturing the real written content, no test-visible knowledge
of the internal constant), and dispatch_allow_failure as a distinct field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:49:38 +02:00
mathiasandClaude Opus 4.8 82823aad1e feat(labels): add label_list + issue_label tools (#52)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
Closes #52 — unblocks parallax#3 dispatch labeling, which needs to both
discover a repo's label set and attach labels to an issue by name (the
CAD pipeline doesn't track Gitea's internal numeric label IDs).

- gitea.Client: ListLabels, AddIssueLabels (additive POST, matches
  Gitea's own semantics — no delete-then-post needed); extend the
  existing Label struct with Color for label_list's output.
- tools.LabelList (read-only, allowlisted): lists a repo's labels.
- tools.IssueLabel (allowlisted): resolves label names to IDs via
  ListLabels, so callers pass names (the primary interface) instead of
  hunting for numeric IDs; also accepts label_ids for callers that
  already have them. An unknown name fails closed, naming exactly which
  label wasn't found.
- Bump TestRegisteredToolCount 39 -> 41 in the same commit (this
  project was bitten today by a locked count going stale silently).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:44:14 +02:00
mathiasandClaude Opus 4.8 64176fe6d7 fix(repo_mirror_push): resolve mirror credential from server env, not the payload (#49)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
The mirror credential no longer has to ride the tool-call payload (which is
persisted to transcript → claudewatcher → brain → gitea history). Adds
remote_password_env: the name of a server-side env var the tool resolves at call
time, so the secret stays in the server process. An env name that resolves to
empty errors loudly rather than silently sending an empty password. Raw
remote_password still works but the schema/description now mark it DISCOURAGED.

Tests: password resolved from the env var (never in output); unset env var →
ErrValidation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:00:07 +02:00
mathiasandClaude Opus 4.8 6d344c74a8 feat(tbd_ship): idempotent re-invoke — resume existing branch/PR (#48)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
A second tbd_ship for the same change no longer errors on "branch exists":
CreateBranch conflict is tolerated, and if a PR is already open for the head,
CreatePullRequest's conflict/validation error resolves it via ListPullRequests
(matching head.ref). Identical file content on the branch skips the write, so a
resume produces no redundant empty-diff commit. Then the same CI gate runs and
merges if now green — so "poll or re-invoke" (the #40 UX) actually works.

Test: TestTBDShip_Resume_ExistingBranchAndPR (branch+PR exist, content
unchanged → no write, merges when green). First-call paths unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:30:30 +02:00
mathiasandClaude Opus 4.8 a515f53731 build(version): inject real build version via ldflags (#47)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
Dockerfile takes ARG VERSION (default "dev") and stamps it into
main.version with -X. CD passes --build-arg VERSION=<git tag on v* builds,
else the short sha>, so the running pod logs the actual build instead of
"dev". Verified locally: `-ldflags -X main.version=...` embeds the string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:28:05 +02:00
mathiasandClaude Opus 4.8 f0527c94bc fix(test): bump TestRegisteredToolCount to 39 for tbd_ship (#40)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
The registered-tool-count lock still expected 38; tbd_ship makes 39. This is
why the v0.6.0 CD check job went red (build+deploy skipped, pod stayed on
v0.5.2). Count updated; task check green (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:49:26 +02:00
mathiasandClaude Opus 4.8 c40a9d9003 test(tbd_ship): cover review-protected + merge-conflict fail-closed paths (#40)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Adds Call-level tests for the two remaining no-merge paths (green CI but the
base requires review, and green CI but the merge returns 409), completing the
acceptance matrix alongside the green/pending/red/no-CI cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:41:12 +02:00
mathiasandClaude Opus 4.8 8ebad95adf feat(tools): add tbd_ship — CI-gated trunk-based ship (#40)
CD / Build & Import (push) Has been skipped
CD / Lint / Test / Vet (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
An intent verb for the trunk-based loop: branch from base → write the file →
open a PR → auto-merge (squash) → delete the branch. One call instead of
orchestrating file_write_branch + pr_create + workflow_run_status + pr_merge +
branch_delete and remembering the conventions each time.

The load-bearing safety is a pure, fail-closed CI gate (evaluateShipGate):
merge=true ONLY when every workflow run for the PR head commit is
completed+success AND the base branch is not review-protected. Every other
state — CI pending / red / absent, review-required base, or an unclean merge —
fails closed to PR-only and returns the PR with a reason. A change with no CI
gate is never auto-merged to trunk (cf. agentsquad#36: non-compiling code
reviewer-approved straight to main with no CI wall).

- ci_timeout_seconds polls the head commit's runs to completion (default 0 =
  snapshot, returns pending right after opening the PR).
- Derives a deterministic short-lived branch (tbd/<slug>-<hash>); handles new
  and existing files (fetches the blob sha for updates).
- Adds head.sha + mergeable to the PR struct.
- Tests: full gate matrix (green/pending/red/cancelled/none/mixed/protected) +
  Call happy-merge, no-CI fail-closed, red fail-closed, allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:40:02 +02:00
mathiasandClaude Opus 4.8 169040c073 chore(context): re-sync adapters from root AGENT.md (skills → mathias/skills repo)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:39:35 +02:00
mathiasandClaude Opus 4.8 834a994af9 chore(housekeeping): canonical git.d-ma.be host + honest version string
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
- Dockerfile: GOPRIVATE and the http→https insteadOf rewrite now name
  git.d-ma.be (was the pre-rename gitea.d-ma.be; masked at build time only by
  GOPROXY=direct + GOSUMDB=off).
- .context/PROJECT.md Repo URL → git.d-ma.be, adapters regenerated
  (CLAUDE.md, AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt).
- main.go: version is now a `-ldflags -X main.version` overridable var defaulting
  to "dev" instead of a hardcoded, drifting "0.1.0".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:58:34 +02:00
mathiasandClaude Opus 4.8 0a12e905c9 fix(create_project): drop defunct hyperguild new-project from finalize guidance (#46)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
The infra#179 partial_failure message and the tool descriptor told callers to
"Finalize locally with `hyperguild new-project`" — but that command does not
exist (the hyperguild CLI only has tier/brain/mode; it was specced, never built).
It pointed users at a dead end.

Extracted the message into a pure infra179FinalizeMessage() and reworded it to
name the actual remaining work — cloning the repo and substituting the leftover
__PROJECT_NAME__ / __MODULE_PATH__ placeholders, or retrying — with no reference
to any scaffolding CLI. Descriptor updated to match. Unit-tested the wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:45:25 +02:00
mathiasandClaude Opus 4.8 72ba89be63 feat(auth): adopt chassis v0.3.0 — auth audit logs + 503 on Dex outage (#6, #9)
CD / Build & Import (push) Successful in 22s
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
Bumps mcp-chassis to v0.3.0, which adds structured audit logging on every auth
rejection and returns 503 temporarily_unavailable (not a silent 401) when Dex is
unreachable at validation time. Wires slog.SetDefault so those audit lines flow
through gitea-mcp's JSON handler.

Together with the earlier /healthz jwt-status reporting and startup degradation
warning, this closes #6 (Dex-down is now observable and distinct from a bad
token) and #9 (auth failures are audit-logged: reason, IP, token type, hashed
fingerprint — never the raw token).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:17:39 +02:00
mathiasandClaude Opus 4.8 9b7f53bdda feat(auth): document caller header precedence + warn on conflict (#10)
CallerMiddleware silently preferred X-Auth-Request-User over X-Forwarded-User
with no explanation and no signal when both were set. Documented the precedence
(X-Auth-Request-User is the verified OIDC identity oauth2-proxy sets, so it is
authoritative; X-Forwarded-User is a fallback), and it now takes a *slog.Logger
and warns when both headers are present and disagree, so a proxy
misconfiguration is visible instead of silently resolved. Table-driven tests
cover precedence (both/single/none) and the conflict-warning path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:57:24 +02:00
32 changed files with 1643 additions and 149 deletions
+2 -2
View File
@@ -263,7 +263,7 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
@@ -288,7 +288,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+1 -1
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+2 -2
View File
@@ -268,7 +268,7 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
@@ -293,7 +293,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+2 -2
View File
@@ -266,7 +266,7 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
@@ -291,7 +291,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+4
View File
@@ -60,7 +60,11 @@ jobs:
run: | run: |
REGISTRY="localhost:5000" REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
# Stamp the real build version: the git tag on a v* build, else the short sha.
VERSION="${{ steps.meta.outputs.version-tag }}"
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
buildah build \ buildah build \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \ --label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \ --label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \ -t ${REF} \
+2 -2
View File
@@ -263,7 +263,7 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
@@ -288,7 +288,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+1 -1
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp - **Name**: gitea-mcp
- **Owner**: Mathias - **Owner**: Mathias
- **Client**: personal - **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp - **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active - **Status**: active
## Stack ## Stack
+7 -4
View File
@@ -1,20 +1,23 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going # Fetch internal git-hosted Go modules (e.g. mcp-chassis) without going
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns # through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
# http:// in its go-import meta tag, so rewrite to https here and bypass # http:// in its go-import meta tag, so rewrite to https here and bypass
# the module proxy + sumdb. # the module proxy + sumdb.
RUN apk add --no-cache git && \ RUN apk add --no-cache git && \
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/" git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
ENV GOPRIVATE=gitea.d-ma.be ENV GOPRIVATE=git.d-ma.be
ENV GOPROXY=direct ENV GOPROXY=direct
ENV GOSUMDB=off ENV GOSUMDB=off
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp # Build version stamped in by CI (--build-arg VERSION=<tag|sha>); defaults to
# "dev" for a plain `docker build` (#47).
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gitea-mcp ./cmd/gitea-mcp
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gitea-mcp /gitea-mcp COPY --from=build /out/gitea-mcp /gitea-mcp
+10 -2
View File
@@ -18,8 +18,16 @@ import (
"git.d-ma.be/mathias/gitea-mcp/internal/tools" "git.d-ma.be/mathias/gitea-mcp/internal/tools"
) )
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
// "0.1.0" — it now tells the truth instead of a stale literal).
var version = "dev"
func main() { func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
// the same structured handler as the rest of the server.
slog.SetDefault(logger)
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -56,7 +64,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL, chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(mcpSrv), auth.CallerMiddleware(logger, mcpSrv),
), ),
)) ))
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr)) mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
@@ -66,7 +74,7 @@ func main() {
} }
addr := ":" + cfg.Port addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0") logger.Info("gitea-mcp starting", "addr", addr, "version", version)
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err) logger.Error("server stopped", "err", err)
os.Exit(1) os.Exit(1)
+1 -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.2.0 git.d-ma.be/mathias/mcp-chassis v0.3.0
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo= git.d-ma.be/mathias/mcp-chassis v0.3.0 h1:lV/vDsjrDeZojT7lhcwolM1lMZpsnEKEvf4kEHrxIa0=
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.3.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+26 -3
View File
@@ -2,17 +2,40 @@ package auth
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
) )
type ctxKey struct{} type ctxKey struct{}
func CallerMiddleware(next http.Handler) http.Handler { // CallerMiddleware extracts the authenticated username from the reverse-proxy
// identity headers and stashes it in the request context for Caller().
//
// Header precedence: X-Auth-Request-User takes priority over X-Forwarded-User.
// X-Auth-Request-User is the header oauth2-proxy sets from the *verified* OIDC
// identity, so it is authoritative. X-Forwarded-User is a weaker, proxy-set
// convention some setups populate instead; it is used only as a fallback when
// X-Auth-Request-User is absent. If a proxy sets BOTH and they disagree, the
// verified X-Auth-Request-User still wins and we log a warning so the
// misconfiguration is visible rather than silently resolved (#10).
//
// logger may be nil, in which case the conflict warning is skipped.
func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("X-Auth-Request-User") authUser := r.Header.Get("X-Auth-Request-User")
fwdUser := r.Header.Get("X-Forwarded-User")
user := authUser
if user == "" { if user == "" {
user = r.Header.Get("X-Forwarded-User") user = fwdUser
} }
if logger != nil && authUser != "" && fwdUser != "" && authUser != fwdUser {
logger.Warn("conflicting caller identity headers; using X-Auth-Request-User",
"x_auth_request_user", authUser,
"x_forwarded_user", fwdUser)
}
ctx := context.WithValue(r.Context(), ctxKey{}, user) ctx := context.WithValue(r.Context(), ctxKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
+63 -9
View File
@@ -1,7 +1,9 @@
package auth_test package auth_test
import ( import (
"bytes"
"context" "context"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -10,17 +12,69 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCallerFromContext(t *testing.T) { func discardLogger() *slog.Logger {
called := false return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), nil))
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { }
called = true
assert.Equal(t, "mathiasbq", auth.Caller(r.Context())) // Header precedence: X-Auth-Request-User (verified OIDC identity) wins over
// X-Forwarded-User, and X-Forwarded-User is only a fallback when the former is
// absent.
func TestCallerHeaderPrecedence(t *testing.T) {
tests := []struct {
name string
authReq string
forwarded string
wantCaller string
}{
{"auth-request only", "mathiasbq", "", "mathiasbq"},
{"forwarded fallback", "", "fwduser", "fwduser"},
{"both present, same", "same", "same", "same"},
{"both present, differ → auth-request wins", "authuser", "fwduser", "authuser"},
{"neither", "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var got string
h := auth.CallerMiddleware(discardLogger(), http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = auth.Caller(r.Context())
})) }))
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Auth-Request-User", "mathiasbq") if tc.authReq != "" {
rr := httptest.NewRecorder() req.Header.Set("X-Auth-Request-User", tc.authReq)
h.ServeHTTP(rr, req) }
assert.True(t, called) if tc.forwarded != "" {
req.Header.Set("X-Forwarded-User", tc.forwarded)
}
h.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, tc.wantCaller, got)
})
}
}
// When both headers are present and disagree, a warning is logged so the proxy
// misconfiguration is visible rather than silent.
func TestCallerConflictingHeadersLogsWarning(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
h := auth.CallerMiddleware(logger, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Auth-Request-User", "authuser")
req.Header.Set("X-Forwarded-User", "fwduser")
h.ServeHTTP(httptest.NewRecorder(), req)
logged := buf.String()
assert.Contains(t, logged, "conflicting")
assert.Contains(t, logged, "authuser")
assert.Contains(t, logged, "fwduser")
// No warning when they agree.
buf.Reset()
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
req2.Header.Set("X-Auth-Request-User", "same")
req2.Header.Set("X-Forwarded-User", "same")
h.ServeHTTP(httptest.NewRecorder(), req2)
assert.Empty(t, buf.String(), "no warning expected when headers agree")
} }
func TestCallerEmptyWhenHeaderMissing(t *testing.T) { func TestCallerEmptyWhenHeaderMissing(t *testing.T) {
+1
View File
@@ -24,6 +24,7 @@ type Issue struct {
type Label struct { type Label struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"`
} }
type User struct { type User struct {
+48
View File
@@ -0,0 +1,48 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
// ListLabels fetches all labels defined on a repo.
func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index
// space with issues, per Gitea). This is additive per Gitea's own POST
// semantics — existing labels are left in place, no replace/delete needed.
// Returns the issue's full label set after the add.
func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number)
payload, err := json.Marshal(map[string][]int64{"labels": labelIDs})
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var labels []Label
if err := json.Unmarshal(body, &labels); err != nil {
return nil, err
}
return labels, nil
}
+107
View File
@@ -0,0 +1,107 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListLabels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
require.Len(t, labels, 2)
assert.Equal(t, int64(1), labels[0].ID)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "ee0701", labels[0].Color)
assert.Equal(t, "enhancement", labels[1].Name)
}
func TestListLabels_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.ListLabels(context.Background(), "o", "r")
require.NoError(t, err)
assert.Empty(t, labels)
}
func TestListLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.ListLabels(context.Background(), "o", "r")
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
func TestAddIssueLabels(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":3,"name":"priority","color":"00ff00"}
]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
require.Len(t, ids, 1)
assert.Equal(t, float64(3), ids[0])
require.Len(t, labels, 2)
assert.Equal(t, "bug", labels[0].Name)
assert.Equal(t, "priority", labels[1].Name)
}
func TestAddIssueLabels_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"issue not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1})
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrNotFound)
}
+2
View File
@@ -16,10 +16,12 @@ type PullRequest struct {
Draft bool `json:"draft"` Draft bool `json:"draft"`
Head struct { Head struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Sha string `json:"sha"`
} `json:"head"` } `json:"head"`
Base struct { Base struct {
Ref string `json:"ref"` Ref string `json:"ref"`
} `json:"base"` } `json:"base"`
Mergeable bool `json:"mergeable"`
} }
type CreatePullRequestArgs struct { type CreatePullRequestArgs struct {
+194 -75
View File
@@ -48,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "create_project_from_template", Name: "create_project_from_template",
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).", Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
@@ -56,8 +56,9 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"}, "description":{"type":"string"},
"private":{"type":"boolean"}, "private":{"type":"boolean"},
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}, "template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."} "dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the project is opt-in for headless dispatch (dispatch#3). Default false. Safe to re-request on resume."},
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
}, },
"required":["owner","name"] "required":["owner","name"]
}`), }`),
@@ -71,6 +72,7 @@ type createProjectArgs struct {
Private bool `json:"private"` Private bool `json:"private"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
DispatchAllow bool `json:"dispatch_allow"` DispatchAllow bool `json:"dispatch_allow"`
Resume bool `json:"resume"`
} }
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the // dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
@@ -86,6 +88,7 @@ type createProjectResult struct {
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"` FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"` PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
} }
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
@@ -104,6 +107,87 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation) return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
} }
var result createProjectResult
var branch string
var err error
if args.Resume {
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
} else {
result, branch, err = t.createDestination(ctx, args)
}
if err != nil {
return nil, err
}
// Substitute across the WHOLE tree: content in every blob, plus a path rename
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
// A fixed known-files list can't rename directories or cover every templated
// file, which is why the old scaffold didn't build. GetTree reflects the
// branch's CURRENT state, which is what makes this loop safe to re-run on
// resume: a file already fixed in a prior partial pass shows up already-correct
// (or already-renamed) and substituteEntry is a no-op for it.
repls := substitutions(args.Owner, args.Name)
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
if terr != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
return textOK(result)
}
for _, e := range tree.Tree {
if e.Type != "blob" {
continue
}
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
if fail != "" {
result.PartialFailure = fail
break
}
if substituted != "" {
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
}
}
// Opt the new project into headless dispatch if asked: presence of a
// .dispatch-allow file on the default branch marks it dispatch-eligible
// (dispatch#3). Skip if substitution itself already stalled — don't mark an
// incomplete repo dispatch-eligible. A failure here is reported in its OWN
// field (gitea-mcp#51) — it must never be indistinguishable from a
// substitution failure, since one can succeed while the other doesn't.
if args.DispatchAllow && result.PartialFailure == "" {
didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch)
if fail != "" {
result.DispatchAllowFailure = fail
} else if didWrite {
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
}
}
// If substitution stalled because the generated branch wasn't writable in time,
// the repo IS created — say so clearly and point to the concrete recovery step
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
// gitea's template-generate is slow-async on this instance, so tool-side
// substitution is best-effort).
if strings.Contains(result.PartialFailure, "branch does not exist") ||
strings.Contains(result.PartialFailure, "not found") {
result.PartialFailure = infra179FinalizeMessage(
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud on a FRESH create with nothing substituted: a real template
// should have placeholders, so finding none is suspicious. On resume, nothing
// left to substitute is the expected steady state once a prior partial run is
// fully caught up — success, not a loud failure.
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
}
return textOK(result)
}
// createDestination generates a new repo from the template: verifies the
// template exists and the destination doesn't already exist, then calls
// gitea's /generate and resolves the default branch.
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
// Resolve template: per-call override takes precedence over the // Resolve template: per-call override takes precedence over the
// server-configured default. Owner stays server-configured. // server-configured default. Owner stays server-configured.
tmplName := args.TemplateName tmplName := args.TemplateName
@@ -114,17 +198,19 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
// Verify template exists and is marked as a template repo. // Verify template exists and is marked as a template repo.
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName) tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
if err != nil { if err != nil {
return nil, fmt.Errorf("template lookup: %w", err) return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
} }
if !tmpl.Template { if !tmpl.Template {
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation) return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
} }
// Verify destination doesn't already exist. // Verify destination doesn't already exist.
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil { if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict) return createProjectResult{}, "", fmt.Errorf(
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) { } else if !errors.Is(err, gitea.ErrNotFound) {
return nil, fmt.Errorf("destination check: %w", err) return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
} }
// Generate repo from template. // Generate repo from template.
@@ -136,7 +222,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
GitContent: true, GitContent: true,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("generate: %w", err) return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
} }
result := createProjectResult{ result := createProjectResult{
@@ -158,73 +244,77 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
} }
} }
result.DefaultBranch = branch result.DefaultBranch = branch
return result, branch, nil
}
// Substitute across the WHOLE tree: content in every blob, plus a path rename // resumeDestination looks up an ALREADY-CREATED repo to continue substitution
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go). // on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
// A fixed known-files list can't rename directories or cover every templated // nothing to resume without it. Template lookup and generation are skipped
// file, which is why the old scaffold didn't build. // entirely: resume only ever operates on the destination.
repls := substitutions(args.Owner, args.Name) func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true) repo, err := t.c.GetRepo(ctx, owner, name)
if err != nil { if err != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err) if errors.Is(err, gitea.ErrNotFound) {
return textOK(result) return createProjectResult{}, "", fmt.Errorf(
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
owner, name, gitea.ErrValidation)
}
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
}
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
return createProjectResult{
FullName: repo.FullName,
HTMLURL: repo.HTMLURL,
CloneURL: repo.CloneURL,
DefaultBranch: branch,
}, branch, nil
} }
for _, e := range tree.Tree { // injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
if e.Type != "blob" { // on every resume, not just the first attempt): creates it if absent, updates
continue // it if present but different, leaves it untouched if already correct. Without
} // this, a naive create-only write would error on a re-invoke (gitea rejects a
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls) // create at a path that already exists) — that was the reported failure in
if fail != "" { // gitea-mcp#51. Returns whether a write actually happened.
result.PartialFailure = fail func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
break const path = ".dispatch-allow"
} sha := ""
if substituted != "" { if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
result.FilesSubstituted = append(result.FilesSubstituted, substituted) if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
return false, "" // already present and correct — idempotent no-op
} }
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
} else if !errors.Is(err, gitea.ErrNotFound) {
return false, fmt.Sprintf("read %s: %v", path, err)
} }
// Opt the new project into headless dispatch if asked: presence of a if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
// .dispatch-allow file on the default branch marks it dispatch-eligible
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
// loop already stalled — a failed injection then degrades identically.
if args.DispatchAllow && result.PartialFailure == "" {
const dispatchAllowPath = ".dispatch-allow"
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
Branch: branch, Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)), Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)", Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
Sha: sha,
}); err != nil { }); err != nil {
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err) return false, fmt.Sprintf("write %s: %v", path, err)
} else {
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
} }
return true, ""
} }
// If substitution stalled because the generated branch wasn't writable in time, // infra179FinalizeMessage explains the best-effort outcome when gitea's slow
// the repo IS created — say so clearly and point to the local finalize step, // async template-generate (infra#179) leaves the branch unwritable within the
// rather than leaking the raw "branch does not exist" (infra#179: gitea's // budget. It points at the concrete recovery step — re-invoking this same tool
// template-generate is slow-async on this instance, so tool-side substitution // with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
// is best-effort). // resume safely continues from wherever substitution stalled.
if strings.Contains(result.PartialFailure, "branch does not exist") || func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
strings.Contains(result.PartialFailure, "not found") { return fmt.Sprintf(
result.PartialFailure = fmt.Sprintf(
"repo created, but its branch (%s) was not writable within %ds — gitea's "+ "repo created, but its branch (%s) was not writable within %ds — gitea's "+
"template-generate is slow-async on this instance (infra#179), so substitution "+ "template-generate is slow-async on this instance (infra#179), so substitution "+
"is incomplete (%d file(s) done). Finalize locally with `hyperguild new-project` "+ "is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
"(clone + substitute, no API race). Underlying: %s", "(same owner/name) once the branch is writable — it safely continues where this left "+
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure) "off, skipping anything already correct. Underlying: %s",
} branch, budget, done, underlying)
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
// substituted (with no explicit failure) means the walk found no placeholders —
// suspicious for a real template. Surface it instead of returning silent success.
if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
}
return textOK(result)
} }
// substitutionBudget bounds how long we retry the first write while the freshly // substitutionBudget bounds how long we retry the first write while the freshly
@@ -232,7 +322,8 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
// before the branch ref is committed, so writes 404 "branch does not exist" for a // before the branch ref is committed, so writes 404 "branch does not exist" for a
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy // window. We keep the budget SHORT so the MCP call stays responsive: a healthy
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s) // gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
// fails fast and we defer substitution with clear guidance rather than hang. // fails fast with partial_failure naming resume=true as the recovery path
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
const substitutionBudget = 5 const substitutionBudget = 5
// upsertRetry retries UpsertFile on the transient post-generate "branch does not // upsertRetry retries UpsertFile on the transient post-generate "branch does not
@@ -283,21 +374,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
enc := base64.StdEncoding.EncodeToString([]byte(newContent)) enc := base64.StdEncoding.EncodeToString([]byte(newContent))
if renamed { if renamed {
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{ return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
Branch: branch,
Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
}); err != nil {
return "", fmt.Sprintf("write %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
Branch: branch,
Sha: fc.Sha,
Message: fmt.Sprintf("template: drop placeholder path %s", path),
}); err != nil {
return "", fmt.Sprintf("delete %s: %v", path, err)
}
return path + " -> " + newPath, ""
} }
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{ if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
@@ -310,3 +387,45 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
} }
return path, "" return path, ""
} }
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
// are separate, non-atomic API calls) completes cleanly instead of erroring on
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
// already holds the correct content, the write is skipped and only the
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
// is a no-op too.
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
switch {
case err == nil:
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
if derr == nil && string(decoded) == newContent {
break // already correct from a prior partial run — skip the write
}
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc, Sha: existing.Sha,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
case errors.Is(err, gitea.ErrNotFound):
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
default:
return "", fmt.Sprintf("read %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
Branch: branch,
Sha: oldSha,
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
}
return oldPath + " -> " + newPath, ""
}
@@ -33,6 +33,7 @@ type fakeTemplateServer struct {
files map[string]string // path -> raw (un-substituted) content files map[string]string // path -> raw (un-substituted) content
genBranch string // default_branch returned by /generate ("" to force fallback) genBranch string // default_branch returned by /generate ("" to force fallback)
generated bool generated bool
generateCalls int // # times POST .../generate was hit — resume must never increment this
puts []string puts []string
deletes []string deletes []string
putBodies map[string]string // path -> decoded written content putBodies map[string]string // path -> decoded written content
@@ -43,6 +44,15 @@ func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTempl
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}} return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
} }
// newFakeTemplateServerResumed simulates a repo that already exists from a
// prior (real) generate call — GET dest succeeds immediately, without a
// /generate call first. Used for resume:true tests.
func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer {
f := newFakeTemplateServer(files, genBranch)
f.generated = true
return f
}
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc { func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock() f.mu.Lock()
@@ -65,6 +75,7 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate": case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
f.generated = true f.generated = true
f.generateCalls++
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
dest, dest, f.genBranch, dest, dest) dest, dest, f.genBranch, dest, dest)
@@ -139,6 +150,7 @@ type createOut struct {
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"` FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"` PartialFailure string `json:"partial_failure,omitempty"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
} }
// Happy path: whole-tree substitution, content + path rename, correct module host. // Happy path: whole-tree substitution, content + path rename, correct module host.
@@ -258,6 +270,149 @@ func TestCreateProject_DispatchAllow(t *testing.T) {
} }
} }
// ── resume: durable substitution against infra#179 (gitea-mcp#50) ───────────
// resume:true requires an ALREADY-CREATED destination — there is nothing to
// resume otherwise.
func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer srv.Close()
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(),
json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), "nothing to resume")
}
// resume:true on an existing repo continues substitution — fixes only what's
// still wrong, leaves already-correct files untouched, and never calls
// /generate again (the whole point: no re-creation, just continuation).
func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run
"README.md": "# __PROJECT_NAME__\n", // still needs substitution
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Equal(t, 0, f.generateCalls, "resume must never call /generate")
assert.Contains(t, out.FilesSubstituted, "README.md")
assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported")
assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten")
assert.Contains(t, f.puts, "README.md")
}
// resume:true when everything is already substituted is the expected steady
// state (a prior resume already finished the job, or this is a redundant
// re-invoke) — success, NOT the "no placeholders substituted" loud failure
// that a fresh (non-resume) create would trigger.
func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
files := map[string]string{
"go.mod": "module git.d-ma.be/mathias/new-svc\n",
"README.md": "# new-svc\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
}
// A resume where a prior partial run's rename write SUCCEEDED but its paired
// delete FAILED (both are separate, non-atomic API calls) must complete
// cleanly: recognize the new path is already correct, skip re-writing it, and
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
files := map[string]string{
// stray: delete never completed in the prior run
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
// already correct: the write half of the same prior rename DID complete
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
assert.Empty(t, out.PartialFailure)
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
}
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
// has the correct content (from an earlier successful injection), re-invoking
// must not attempt another write — and must not error the way a naive
// create-only write would (gitea 409/422 on an existing path with no sha).
func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) {
// Phase 1: a normal (non-resume) call captures the REAL content the tool
// writes for .dispatch-allow, without the test needing to know the exact
// unexported constant.
seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
seedSrv := newFakeTemplateServer(seedFiles, "main")
srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc"))
out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
srv1.Close()
require.Empty(t, out1.PartialFailure)
realContent := seedSrv.putBodies[".dispatch-allow"]
require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow")
// Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus
// one file still needing substitution.
files := map[string]string{
".dispatch-allow": realContent,
"README.md": "# __PROJECT_NAME__\n",
}
f := newFakeTemplateServerResumed(files, "main")
srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
defer srv2.Close()
out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`)
assert.Empty(t, out2.PartialFailure)
assert.Empty(t, out2.DispatchAllowFailure)
assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten")
assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted")
assert.Contains(t, out2.FilesSubstituted, "README.md")
}
// dispatch_allow injection failing is reported in its OWN field, distinct from
// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch
// eligibility still fails, and the caller must be able to tell them apart.
func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) {
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
f := newFakeTemplateServer(files, "main")
base := f.handler(t, "template-go-agent", "new-svc")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
return
}
base(w, r)
}))
defer srv.Close()
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`)
assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure")
assert.NotEmpty(t, out.DispatchAllowFailure)
assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow")
assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure")
}
// ── guardrails unchanged by the rewrite ────────────────────────────────────── // ── guardrails unchanged by the rewrite ──────────────────────────────────────
func TestCreateProject_NameRegexFailure(t *testing.T) { func TestCreateProject_NameRegexFailure(t *testing.T) {
@@ -0,0 +1,23 @@
package tools
import (
"strings"
"testing"
)
// #46: the infra#179 finalize guidance must not point at a non-existent command
// (`hyperguild new-project` was never built). #50 superseded the original
// manual-editing guidance with a concrete, real recovery: re-invoke this same
// tool with resume=true.
func TestInfra179FinalizeMessage(t *testing.T) {
msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist")
for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q\ngot: %s", want, msg)
}
}
if strings.Contains(msg, "hyperguild new-project") {
t.Errorf("message must not reference the defunct `hyperguild new-project` command\ngot: %s", msg)
}
}
+87
View File
@@ -0,0 +1,87 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueLabel struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel {
return &IssueLabel{c: c, a: a}
}
func (t *IssueLabel) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_label",
Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"number":{"type":"integer","minimum":1},
"labels":{"type":"array","items":{"type":"string"},"description":"Label names to resolve and apply. Either labels or label_ids is required."},
"label_ids":{"type":"array","items":{"type":"integer"},"description":"Label IDs to apply directly, skipping name resolution. Either labels or label_ids is required."}
},
"required":["owner","repo","number"]
}`),
}
}
type issueLabelArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Labels []string `json:"labels,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
}
func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueLabelArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
if len(args.Labels) == 0 && len(args.LabelIDs) == 0 {
return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation)
}
ids := append([]int64{}, args.LabelIDs...)
if len(args.Labels) > 0 {
existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
byName := make(map[string]int64, len(existing))
for _, l := range existing {
byName[l.Name] = l.ID
}
for _, name := range args.Labels {
id, ok := byName[name]
if !ok {
return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation)
}
ids = append(ids, id)
}
}
labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids)
if err != nil {
return nil, err
}
return textOK(labels)
}
+128
View File
@@ -0,0 +1,128 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const labelListFixture = `[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`
func TestIssueLabelAppliesByName(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
// label_ids alone (no labels) must work end-to-end without hitting ListLabels
// at all — this is the schema-level "either labels or label_ids" contract, and
// it must never require a GET to the label list when the caller already has IDs.
func TestIssueLabelAppliesByIDOnly(t *testing.T) {
var captured []byte
var listCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels":
listCalled = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels":
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"label_ids":[1,2]}`))
require.NoError(t, err)
assert.False(t, listCalled, "label_ids-only must not call ListLabels")
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
ids, ok := payload["labels"].([]any)
require.True(t, ok)
assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids)
assert.Contains(t, string(out), `"name":"bug"`)
}
// #52 review finding: the advertised schema wrongly required "labels", making
// label_ids-only calls fail JSON-Schema validation before reaching Call at all.
// Lock the fixed contract: neither is individually required.
func TestIssueLabelSchema_NeitherLabelsNorLabelIDsRequired(t *testing.T) {
sch := string(tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})).Descriptor().InputSchema)
assert.NotContains(t, sch, `"required":["owner","repo","number","labels"]`)
assert.Contains(t, sch, `"required":["owner","repo","number"]`)
}
func TestIssueLabelUnknownNameNamesTheMissingLabel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(labelListFixture))
}))
defer srv.Close()
tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Contains(t, err.Error(), `"does-not-exist"`)
assert.Contains(t, err.Error(), "o/r")
}
func TestIssueLabelAllowlistRejects(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`))
require.Error(t, err)
}
func TestIssueLabelRequiresValidNumber(t *testing.T) {
tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
+54
View File
@@ -0,0 +1,54 @@
package tools
import (
"context"
"encoding/json"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type LabelList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList {
return &LabelList{c: c, a: a}
}
func (t *LabelList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "label_list",
Description: "List all labels defined on a repo. Returns id, name, and color for each label.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"}
},
"required":["owner","repo"]
}`),
}
}
type labelListArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args labelListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
if err != nil {
return nil, err
}
return textOK(labels)
}
+42
View File
@@ -0,0 +1,42 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLabelListTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/mathias/infra/labels", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id":1,"name":"bug","color":"ee0701"},
{"id":2,"name":"enhancement","color":"84b6eb"}
]`))
}))
defer srv.Close()
tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"id":1`)
assert.Contains(t, string(out), `"name":"bug"`)
assert.Contains(t, string(out), `"color":"ee0701"`)
assert.Contains(t, string(out), `"name":"enhancement"`)
}
func TestLabelListAllowlistRejects(t *testing.T) {
tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`))
require.Error(t, err)
}
+1 -1
View File
@@ -30,7 +30,7 @@ const prFixture = `{
func callerContext(user string) context.Context { func callerContext(user string) context.Context {
var capturedCtx context.Context var capturedCtx context.Context
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { h := auth.CallerMiddleware(nil, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedCtx = r.Context() capturedCtx = r.Context()
})) }))
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequest("POST", "/", nil)
+3
View File
@@ -33,6 +33,7 @@ func RegisterAll(
reg.Register(NewPRGet(c, a)) reg.Register(NewPRGet(c, a))
reg.Register(NewPRList(c, a)) reg.Register(NewPRList(c, a))
reg.Register(NewPRMerge(c, a)) reg.Register(NewPRMerge(c, a))
reg.Register(NewTBDShip(c, a))
reg.Register(NewPRComment(c, a)) reg.Register(NewPRComment(c, a))
reg.Register(NewPRFilesDiff(c, a)) reg.Register(NewPRFilesDiff(c, a))
reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL)) reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL))
@@ -53,6 +54,8 @@ func RegisterAll(
reg.Register(NewIssueListComments(c, a)) reg.Register(NewIssueListComments(c, a))
reg.Register(NewIssueClose(c, a)) reg.Register(NewIssueClose(c, a))
reg.Register(NewIssueReopen(c, a)) reg.Register(NewIssueReopen(c, a))
reg.Register(NewLabelList(c, a))
reg.Register(NewIssueLabel(c, a))
reg.Register(NewWorkflowRunList(c, a)) reg.Register(NewWorkflowRunList(c, a))
reg.Register(NewReleaseCreate(c, a)) reg.Register(NewReleaseCreate(c, a))
reg.Register(NewRepoDelete(c, a)) reg.Register(NewRepoDelete(c, a))
+1 -1
View File
@@ -54,5 +54,5 @@ func TestEveryRegisteredToolIsDispatchable(t *testing.T) {
// Lock the tool count so an accidental drop of a registration in RegisterAll // Lock the tool count so an accidental drop of a registration in RegisterAll
// (the single source main.go and this test share) fails loudly. // (the single source main.go and this test share) fails loudly.
func TestRegisteredToolCount(t *testing.T) { func TestRegisteredToolCount(t *testing.T) {
assert.Len(t, buildRegistry().Tools(), 38) assert.Len(t, buildRegistry().Tools(), 41)
} }
+26 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "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/gitea"
@@ -22,7 +23,7 @@ func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush
func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor { func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "repo_mirror_push", Name: "repo_mirror_push",
Description: "Manage push mirrors for a repository: add, list, or delete.", Description: "Manage push mirrors for a repository: add, list, or delete. For the mirror credential, PREFER remote_password_env (the name of an env var the server reads) so the secret never rides the tool-call payload/transcript; remote_password (raw) is discouraged and will be persisted in logs.",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
@@ -31,7 +32,8 @@ func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
"action":{"type":"string","enum":["add","list","delete"]}, "action":{"type":"string","enum":["add","list","delete"]},
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."}, "remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
"remote_username":{"type":"string"}, "remote_username":{"type":"string"},
"remote_password":{"type":"string","description":"Never logged or returned."}, "remote_password_env":{"type":"string","description":"PREFERRED: name of a server-side env var holding the mirror credential; the server resolves it, so the secret is never in this call. Errors if the var is unset."},
"remote_password":{"type":"string","description":"DISCOURAGED: raw credential — lands in the tool-call transcript/logs. Use remote_password_env instead."},
"interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."}, "interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."},
"sync_on_commit":{"type":"boolean"}, "sync_on_commit":{"type":"boolean"},
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."} "mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
@@ -48,6 +50,7 @@ type repoMirrorPushArgs struct {
RemoteAddress string `json:"remote_address"` RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username"` RemoteUsername string `json:"remote_username"`
RemotePassword string `json:"remote_password"` RemotePassword string `json:"remote_password"`
RemotePasswordEnv string `json:"remote_password_env"`
Interval string `json:"interval"` Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"` SyncOnCommit bool `json:"sync_on_commit"`
MirrorName string `json:"mirror_name"` MirrorName string `json:"mirror_name"`
@@ -72,6 +75,22 @@ func toSafeMirror(m *gitea.PushMirror) safeMirror {
} }
} }
// resolveMirrorPassword prefers remote_password_env — the name of a server-side
// env var — so the credential never appears in the tool-call payload (#49). It
// falls back to the raw (discouraged) remote_password. An env name that resolves
// to empty is a loud error, not a silent empty password.
func resolveMirrorPassword(args repoMirrorPushArgs) (string, error) {
if args.RemotePasswordEnv != "" {
pw := os.Getenv(args.RemotePasswordEnv)
if pw == "" {
return "", fmt.Errorf("remote_password_env %q is unset or empty in the server environment: %w",
args.RemotePasswordEnv, gitea.ErrValidation)
}
return pw, nil
}
return args.RemotePassword, nil
}
func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoMirrorPushArgs var args repoMirrorPushArgs
if err := parseArgs(raw, &args); err != nil { if err := parseArgs(raw, &args); err != nil {
@@ -82,10 +101,14 @@ func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.Ra
} }
switch args.Action { switch args.Action {
case "add": case "add":
password, err := resolveMirrorPassword(args)
if err != nil {
return nil, err
}
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{ m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{
RemoteAddress: args.RemoteAddress, RemoteAddress: args.RemoteAddress,
RemoteUsername: args.RemoteUsername, RemoteUsername: args.RemoteUsername,
RemotePassword: args.RemotePassword, RemotePassword: password,
Interval: args.Interval, Interval: args.Interval,
SyncOnCommit: args.SyncOnCommit, SyncOnCommit: args.SyncOnCommit,
}) })
+40
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"
"testing" "testing"
@@ -14,6 +15,45 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// #49: remote_password_env names a server-side env var; the secret is resolved
// from the server environment and never rides the tool-call payload.
func TestRepoMirrorPushTool_PasswordFromEnv(t *testing.T) {
t.Setenv("TEST_MIRROR_PW", "env-secret")
var gotPw string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
gotPw, _ = m["remote_password"].(string)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"remote_name":"m","remote_address":"a"}`))
}))
defer srv.Close()
tool := tools.NewRepoMirrorPush(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"mathias","name":"infra","action":"add",
"remote_address":"https://github.com/mathias/infra.git",
"remote_username":"mathias","remote_password_env":"TEST_MIRROR_PW"
}`))
require.NoError(t, err)
assert.Equal(t, "env-secret", gotPw, "password must be resolved from the server env var")
assert.NotContains(t, string(out), "env-secret")
}
// remote_password_env pointing at an unset var must fail loudly, not silently
// send an empty password.
func TestRepoMirrorPushTool_EnvUnsetErrors(t *testing.T) {
tool := tools.NewRepoMirrorPush(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"mathias","name":"infra","action":"add",
"remote_address":"https://github.com/x/y.git","remote_username":"u",
"remote_password_env":"DEFINITELY_UNSET_MIRROR_VAR_XYZ"
}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
func TestRepoMirrorPushTool_Add(t *testing.T) { func TestRepoMirrorPushTool_Add(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, http.MethodPost, r.Method)
+306
View File
@@ -0,0 +1,306 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// TBDShip is the intent verb for the trunk-based loop: branch → write → PR →
// (CI-green) auto-merge → clean up. The safety is the CI gate — it fails closed
// on anything that isn't a fully-green, unprotected, cleanly-mergeable change.
type TBDShip struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewTBDShip(c *gitea.Client, a *allowlist.Allowlist) *TBDShip { return &TBDShip{c: c, a: a} }
const (
shipCIPollInterval = 5 * time.Second
shipCIMaxTimeoutSec = 600
)
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
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}.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"repo":{"type":"string"},
"path":{"type":"string","description":"File path to create or update."},
"content":{"type":"string","description":"Full file content (plain text)."},
"message":{"type":"string","description":"Commit message."},
"base":{"type":"string","description":"Base branch to ship to. Default 'main'."},
"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_body":{"type":"string"},
"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)."}
},
"required":["owner","repo","path","content","message"]
}`),
}
}
type tbdShipArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Path string `json:"path"`
Content string `json:"content"`
Message string `json:"message"`
Base string `json:"base"`
Branch string `json:"branch"`
PRTitle string `json:"pr_title"`
PRBody string `json:"pr_body"`
DeleteBranch *bool `json:"delete_branch"`
CITimeoutSec int `json:"ci_timeout_seconds"`
}
// shipGate is the decision produced by evaluateShipGate.
type shipGate struct {
merge bool
ciStatus string // none | pending | failed | success
reason string // non-empty iff merge == false
}
// classifyCI reduces a set of workflow runs for one commit to a single status.
// Fail-closed: only "success" (all runs completed and successful) permits a
// merge; anything else — no runs, still-running, or any non-success conclusion
// (failure/cancelled/skipped) — blocks it.
func classifyCI(runs []gitea.WorkflowRun) string {
if len(runs) == 0 {
return "none"
}
for _, r := range runs {
if r.Status != "completed" {
return "pending"
}
}
for _, r := range runs {
if r.Conclusion != "success" {
return "failed"
}
}
return "success"
}
// 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
// other state fails closed to PR-only with an explanatory reason (#40).
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection) shipGate {
ci := classifyCI(runs)
// Review-protected base can't be auto-merged regardless of CI.
if bp != nil && bp.Protected && bp.RequiredApprovals > 0 {
return shipGate{false, ci, fmt.Sprintf(
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)}
}
switch ci {
case "success":
return shipGate{true, ci, ""}
case "pending":
return shipGate{false, ci, "CI is still running for the head commit — PR opened; re-invoke once checks complete, or merge manually"}
case "failed":
return shipGate{false, ci, "CI failed for the head commit — PR opened, not merged"}
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"}
}
}
var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
// deriveBranch builds a deterministic short-lived branch name from the change,
// so the same change maps to the same branch and distinct changes don't collide.
func deriveBranch(message, path, content string) string {
slug := strings.Trim(shipSlugRe.ReplaceAllString(strings.ToLower(message), "-"), "-")
if len(slug) > 32 {
slug = strings.Trim(slug[:32], "-")
}
if slug == "" {
slug = "change"
}
sum := sha256.Sum256([]byte(path + "\x00" + content + "\x00" + message))
return "tbd/" + slug + "-" + hex.EncodeToString(sum[:])[:8]
}
func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args tbdShipArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Path == "" || args.Content == "" || args.Message == "" {
return nil, fmt.Errorf("path, content, and message are required: %w", gitea.ErrValidation)
}
base := args.Base
if base == "" {
base = "main"
}
branch := args.Branch
if branch == "" {
branch = deriveBranch(args.Message, args.Path, args.Content)
}
deleteBranch := true
if args.DeleteBranch != nil {
deleteBranch = *args.DeleteBranch
}
// Probe base-branch protection up front — a real error fails closed.
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, base)
if err != nil {
return nil, fmt.Errorf("branch protection probe: %w", err)
}
// Branch from base. A conflict means the branch already exists — resume on it
// (idempotent re-invoke, #48) rather than failing.
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, branch, base); err != nil && !errors.Is(err, gitea.ErrConflict) {
return nil, fmt.Errorf("create branch %s: %w", branch, err)
}
// A pre-existing file at path needs its blob sha to update; a new file does
// not. If the content already on the branch is identical, skip the write so a
// resume doesn't produce a redundant empty-diff commit.
sha := ""
needWrite := true
if fc, ferr := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, branch); ferr == nil {
sha = fc.Sha
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == args.Content {
needWrite = false
}
} else if !errors.Is(ferr, gitea.ErrNotFound) {
return nil, fmt.Errorf("read %s on %s: %w", args.Path, branch, ferr)
}
if needWrite {
if _, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(args.Content)),
Message: args.Message,
Sha: sha,
}); err != nil {
return nil, fmt.Errorf("write %s on %s: %w", args.Path, branch, err)
}
}
prTitle := args.PRTitle
if prTitle == "" {
prTitle = args.Message
}
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Repo, gitea.CreatePullRequestArgs{
Title: prTitle,
Body: identity.ApplyFooter(args.PRBody, auth.Caller(ctx)),
Head: branch,
Base: base,
})
if err != nil {
// An open PR for this head already exists → resume it (#48).
if errors.Is(err, gitea.ErrConflict) || errors.Is(err, gitea.ErrValidation) {
pr, err = t.findOpenPR(ctx, args.Owner, args.Repo, branch)
}
if err != nil {
return nil, fmt.Errorf("open PR: %w", err)
}
}
// CI gate on the PR head commit.
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
gate := evaluateShipGate(runs, bp)
result := map[string]any{
"merged": false,
"pr_number": pr.Number,
"pr_url": pr.HTMLURL,
"branch": branch,
"ci_status": gate.ciStatus,
}
if !gate.merge {
result["reason"] = gate.reason
return textOK(result)
}
// 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 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"
return textOK(result)
}
return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err)
}
result["merged"] = true
if deleteBranch {
if derr := t.c.DeleteBranch(ctx, args.Owner, args.Repo, branch); derr == nil {
result["branch_deleted"] = true
}
}
return textOK(result)
}
// 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
// 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 {
if timeoutSec < 0 {
timeoutSec = 0
}
if timeoutSec > shipCIMaxTimeoutSec {
timeoutSec = shipCIMaxTimeoutSec
}
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for {
runs := t.listRuns(ctx, owner, repo, headSHA)
switch classifyCI(runs) {
case "success", "failed":
return runs // terminal — no point waiting
}
if time.Now().After(deadline) {
return runs // out of time — return whatever we have (none/pending)
}
select {
case <-ctx.Done():
return runs
case <-time.After(shipCIPollInterval):
}
}
}
// findOpenPR returns the open PR whose head is the given branch — used to resume
// an existing PR when CreatePullRequest reports one already exists (#48).
func (t *TBDShip) findOpenPR(ctx context.Context, owner, repo, branch string) (*gitea.PullRequest, error) {
prs, err := t.c.ListPullRequests(ctx, owner, repo, "open", branch, 1, 50)
if err != nil {
return nil, err
}
for i := range prs {
if prs[i].Head.Ref == branch {
return &prs[i], nil
}
}
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 {
resp, err := t.c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
if err != nil || resp == nil {
return nil
}
return resp.WorkflowRuns
}
+53
View File
@@ -0,0 +1,53 @@
package tools
import (
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// The ship gate is the load-bearing safety of tbd_ship: it must NEVER return
// merge=true unless CI is fully green AND the base isn't review-protected.
// Every non-green / unknown / protected state fails closed to PR-only (#40).
func TestEvaluateShipGate(t *testing.T) {
run := func(status, concl string) gitea.WorkflowRun {
return gitea.WorkflowRun{Status: status, Conclusion: concl}
}
protected := &gitea.BranchProtection{Protected: true, RequiredApprovals: 1}
open := &gitea.BranchProtection{Protected: false}
tests := []struct {
name string
runs []gitea.WorkflowRun
bp *gitea.BranchProtection
wantMerge bool
wantCI string
}{
{"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, true, "success"},
{"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, false, "pending"},
{"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, false, "pending"},
{"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, false, "failed"},
{"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, false, "failed"},
{"no runs → no merge (no CI gate)", nil, open, false, "none"},
{"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, false, "pending"},
{"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, false, "success"},
{"no runs + protected → no merge", nil, protected, false, "none"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := evaluateShipGate(tc.runs, tc.bp)
if g.merge != tc.wantMerge {
t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason)
}
if g.ciStatus != tc.wantCI {
t.Errorf("ciStatus = %q, want %q", g.ciStatus, tc.wantCI)
}
if !tc.wantMerge && g.reason == "" {
t.Errorf("no-merge decision must carry a reason")
}
if tc.wantMerge && g.reason != "" {
t.Errorf("merge decision must have empty reason, got %q", g.reason)
}
})
}
}
+211
View File
@@ -0,0 +1,211 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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"
)
// shipFake serves the whole tbd_ship flow; runsJSON is the workflow_runs array
// for the head commit, letting each test drive the CI gate.
func shipFake(t *testing.T, runsJSON string, merged, deleted *atomic.Bool) *httptest.Server {
t.Helper()
return shipFakeOpts(t, runsJSON, 0, http.StatusOK, merged, deleted)
}
// shipFakeOpts adds protection (requiredApprovals>0 → protected) and a merge
// 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 {
t.Helper()
return 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 && strings.Contains(p, "/branch_protections/"):
if requiredApprovals > 0 {
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(requiredApprovals) + `}`))
return
}
w.WriteHeader(http.StatusNotFound) // unprotected
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/branches"):
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"tbd/x","commit":{"id":"abc"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/contents/"):
w.WriteHeader(http.StatusNotFound) // new file
_, _ = w.Write([]byte(`{"message":"not found"}`))
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.Contains(p, "/contents/"):
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"s"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls"):
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"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + runsJSON + `}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls/7/merge"):
merged.Store(true)
w.WriteHeader(mergeStatus)
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodDelete && strings.Contains(p, "/branches/"):
deleted.Store(true)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func callShip(t *testing.T, srvURL, args string) map[string]any {
t.Helper()
tool := tools.NewTBDShip(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(args))
require.NoError(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
return res
}
// Green CI + unprotected base → squash-merge + branch deleted.
func TestTBDShip_GreenCI_Merges(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, &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, true, res["merged"])
assert.Equal(t, "success", res["ci_status"])
assert.Equal(t, float64(7), res["pr_number"])
assert.True(t, merged.Load(), "merge endpoint must be called")
assert.True(t, deleted.Load(), "branch must be deleted after merge")
}
// No CI runs for the head commit → fail closed: PR opened, NOT merged.
func TestTBDShip_NoCI_FailsClosed(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFake(t, `[]`, &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.Equal(t, "none", res["ci_status"])
assert.NotEmpty(t, res["reason"])
assert.False(t, merged.Load(), "merge must NOT be called when there is no CI")
assert.Equal(t, float64(7), res["pr_number"], "PR is still opened for manual merge")
}
// Red CI → fail closed.
func TestTBDShip_RedCI_FailsClosed(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFake(t, `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`, &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.Equal(t, "failed", res["ci_status"])
assert.False(t, merged.Load(), "merge must NOT be called when CI is red")
}
func itoa(n int) string { b, _ := json.Marshal(n); return string(b) }
// Green CI but the base branch requires review → fail closed (no auto-merge).
func TestTBDShip_ReviewProtected_FailsClosed(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 1, http.StatusOK, &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.NotEmpty(t, res["reason"])
assert.Contains(t, res["reason"], "review")
assert.False(t, merged.Load(), "must NOT merge a review-protected base")
}
// Green CI but the merge is unclean (409) → fail closed, PR left open.
func TestTBDShip_MergeConflict_FailsClosed(t *testing.T) {
var merged, deleted atomic.Bool
srv := shipFakeOpts(t, `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`, 0, http.StatusConflict, &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.NotEmpty(t, res["reason"])
assert.True(t, merged.Load(), "merge is attempted")
assert.False(t, deleted.Load(), "branch is NOT deleted on a failed merge")
}
// Re-invoking with the same change must resume the existing branch/PR (not
// error on "branch exists"), skip the redundant write when content is
// unchanged, and merge once CI is green (#48).
func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
var merged, deleted, wrote atomic.Bool
branch := "tbd/resume-me"
srv := 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 && strings.Contains(p, "/branch_protections/"):
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/branches"):
w.WriteHeader(http.StatusConflict) // branch already exists
_, _ = w.Write([]byte(`{"message":"branch already exists"}`))
case r.Method == http.MethodGet && strings.Contains(p, "/contents/"):
// existing file with identical content ("hi") → write should be skipped
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"path":"docs/x.md","sha":"s","content":"aGk=","encoding":"base64"}`))
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.Contains(p, "/contents/"):
wrote.Store(true)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"s2"},"commit":{"sha":"c"}}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls"):
w.WriteHeader(http.StatusConflict) // PR already exists for this head
_, _ = w.Write([]byte(`{"message":"pull request already exists"}`))
case r.Method == http.MethodGet && strings.HasSuffix(p, "/pulls"):
_, _ = w.Write([]byte(`[{"number":7,"html_url":"http://x/pulls/7","state":"open","head":{"ref":"` + branch + `","sha":"abc"},"base":{"ref":"main"}}]`))
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
_, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]}`))
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls/7/merge"):
merged.Store(true)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodDelete && strings.Contains(p, "/branches/"):
deleted.Store(true)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
res := callShip(t, srv.URL, `{"owner":"mathias","repo":"myrepo","path":"docs/x.md","content":"hi","message":"add x","branch":"`+branch+`"}`)
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.False(t, wrote.Load(), "identical content must not trigger a redundant write")
assert.True(t, merged.Load())
}
func TestTBDShip_AllowlistRejects(t *testing.T) {
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"}`))
require.Error(t, err)
}