diff --git a/internal/gitea/pulls.go b/internal/gitea/pulls.go index 529e2c9..a889c63 100644 --- a/internal/gitea/pulls.go +++ b/internal/gitea/pulls.go @@ -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 { diff --git a/internal/tools/registry.go b/internal/tools/registry.go index abbc06d..d6f60a1 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -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)) diff --git a/internal/tools/tbd_ship.go b/internal/tools/tbd_ship.go new file mode 100644 index 0000000..3253f7c --- /dev/null +++ b/internal/tools/tbd_ship.go @@ -0,0 +1,276 @@ +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/-."}, + "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, write the change, open the PR. + if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, branch, base); err != nil { + 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. + sha := "" + if fc, ferr := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, branch); ferr == nil { + sha = fc.Sha + } else if !errors.Is(ferr, gitea.ErrNotFound) { + return nil, fmt.Errorf("read %s on %s: %w", args.Path, branch, ferr) + } + 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 { + 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): + } + } +} + +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 +} diff --git a/internal/tools/tbd_ship_internal_test.go b/internal/tools/tbd_ship_internal_test.go new file mode 100644 index 0000000..7109408 --- /dev/null +++ b/internal/tools/tbd_ship_internal_test.go @@ -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) + } + }) + } +} diff --git a/internal/tools/tbd_ship_test.go b/internal/tools/tbd_ship_test.go new file mode 100644 index 0000000..231ebcd --- /dev/null +++ b/internal/tools/tbd_ship_test.go @@ -0,0 +1,116 @@ +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 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) // 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(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) + } + })) +} + +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 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) +}