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 }