Compare commits

...
8 Commits
Author SHA1 Message Date
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
17 changed files with 676 additions and 28 deletions
+2 -2
View File
@@ -263,7 +263,7 @@ unconditionally on every host, every harness.
## 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:**
@@ -288,7 +288,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+1 -1
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+2 -2
View File
@@ -268,7 +268,7 @@ unconditionally on every host, every harness.
## 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:**
@@ -293,7 +293,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+2 -2
View File
@@ -266,7 +266,7 @@ unconditionally on every host, every harness.
## 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:**
@@ -291,7 +291,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+4
View File
@@ -60,7 +60,11 @@ jobs:
run: |
REGISTRY="localhost:5000"
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 \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
+2 -2
View File
@@ -263,7 +263,7 @@ unconditionally on every host, every harness.
## 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:**
@@ -288,7 +288,7 @@ Shared engineering skills are available in `~/dev/.skills/`. Load at task start
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+1 -1
View File
@@ -9,7 +9,7 @@
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://gitea.d-ma.be/mathias/gitea-mcp
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
+7 -4
View File
@@ -1,20 +1,23 @@
FROM golang:1.26-alpine AS build
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
# http:// in its go-import meta tag, so rewrite to https here and bypass
# the module proxy + sumdb.
RUN apk add --no-cache git && \
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
ENV GOPRIVATE=gitea.d-ma.be
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
ENV GOPRIVATE=git.d-ma.be
ENV GOPROXY=direct
ENV GOSUMDB=off
COPY go.mod go.sum ./
RUN go mod download
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
COPY --from=build /out/gitea-mcp /gitea-mcp
+6 -1
View File
@@ -18,6 +18,11 @@ import (
"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() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
@@ -69,7 +74,7 @@ func main() {
}
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 {
logger.Error("server stopped", "err", err)
os.Exit(1)
+2
View File
@@ -16,10 +16,12 @@ type PullRequest struct {
Draft bool `json:"draft"`
Head struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
Mergeable bool `json:"mergeable"`
}
type CreatePullRequestArgs struct {
+1
View File
@@ -33,6 +33,7 @@ func RegisterAll(
reg.Register(NewPRGet(c, a))
reg.Register(NewPRList(c, a))
reg.Register(NewPRMerge(c, a))
reg.Register(NewTBDShip(c, a))
reg.Register(NewPRComment(c, a))
reg.Register(NewPRFilesDiff(c, a))
reg.Register(NewWorkflowRunTrigger(c, a, giteaBaseURL))
+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
// (the single source main.go and this test share) fails loudly.
func TestRegisteredToolCount(t *testing.T) {
assert.Len(t, buildRegistry().Tools(), 38)
assert.Len(t, buildRegistry().Tools(), 39)
}
+35 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"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 {
return registry.ToolDescriptor{
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(`{
"type":"object",
"properties":{
@@ -31,7 +32,8 @@ func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
"action":{"type":"string","enum":["add","list","delete"]},
"remote_address":{"type":"string","description":"Mirror target URL (required for add)."},
"remote_username":{"type":"string"},
"remote_password":{"type":"string","description":"Never logged or returned."},
"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'."},
"sync_on_commit":{"type":"boolean"},
"mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."}
@@ -42,15 +44,16 @@ func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor {
}
type repoMirrorPushArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Action string `json:"action"`
RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username"`
RemotePassword string `json:"remote_password"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
MirrorName string `json:"mirror_name"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Action string `json:"action"`
RemoteAddress string `json:"remote_address"`
RemoteUsername string `json:"remote_username"`
RemotePassword string `json:"remote_password"`
RemotePasswordEnv string `json:"remote_password_env"`
Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"`
MirrorName string `json:"mirror_name"`
}
// safeMirror omits remote_password so it is never returned to the caller.
@@ -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) {
var args repoMirrorPushArgs
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 {
case "add":
password, err := resolveMirrorPassword(args)
if err != nil {
return nil, err
}
m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{
RemoteAddress: args.RemoteAddress,
RemoteUsername: args.RemoteUsername,
RemotePassword: args.RemotePassword,
RemotePassword: password,
Interval: args.Interval,
SyncOnCommit: args.SyncOnCommit,
})
+40
View File
@@ -3,6 +3,7 @@ package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -14,6 +15,45 @@ import (
"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) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
}