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" ) // PRMerge merges an existing PR behind the same gate as tbd_ship (gitea-mcp#74, #63): CI fully green, base not // review-protected, and the issue named in `issue` classified exactly risk/low. Above risk/low a human reviews and // merges in the Gitea UI; this tool then merges nothing and says why. type PRMerge struct { c *gitea.Client a *allowlist.Allowlist } func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge { return &PRMerge{c: c, a: a} } func (t *PRMerge) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ Name: "pr_merge", Description: "Merge a pull request, gated like tbd_ship: it merges ONLY when the head commit's CI is fully green AND the base branch is not review-protected AND the issue named in `issue` carries exactly one risk/ label, risk/low. Otherwise it merges nothing and returns merged:false with a reason and the risk tier (no issue named, no or several risk/ labels, an unknown value, risk/medium, risk/high, an unreadable issue, CI pending/red/absent, a review-protected base). Above risk/low a human reviews and merges in the Gitea UI. style: merge (default), squash, or rebase. Set ci_timeout_seconds to poll CI to completion (default 0 = single snapshot). Returns {merged, pr_number, ci_status, risk, reason}.", InputSchema: json.RawMessage(`{ "type":"object", "properties":{ "owner":{"type":"string"}, "repo":{"type":"string"}, "number":{"type":"integer","minimum":1}, "issue":{"type":"integer","minimum":1,"description":"Issue number in the same repo whose risk/ label classifies this change. Merge happens only for exactly one label, risk/low; no issue, none, several, an unknown value, risk/medium or risk/high means nothing is merged (unclassified is not low)."}, "style":{"type":"string","enum":["merge","squash","rebase"]}, "merge_message_title":{"type":"string"}, "merge_message_field":{"type":"string"}, "ci_timeout_seconds":{"type":"integer","minimum":0,"maximum":600,"description":"Poll CI up to this long for the head commit's runs to complete. Default 0 (single snapshot)."} }, "required":["owner","repo","number"] }`), } } type prMergeArgs struct { Owner string `json:"owner"` Repo string `json:"repo"` Number int `json:"number"` Issue int `json:"issue"` Style string `json:"style"` Title string `json:"merge_message_title"` Body string `json:"merge_message_field"` CITimeoutSec int `json:"ci_timeout_seconds"` } func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args prMergeArgs if err := parseArgs(raw, &args); err != nil { return nil, err } if err := t.a.Check(ctx, args.Owner); err != nil { return nil, err } if args.Number < 1 { return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation) } style := args.Style if style == "" { style = "merge" } // The gate's inputs. An unreadable PR or branch-protection probe is an error: the gate cannot be evaluated, so // nothing is merged. An unreadable issue is a tier (unreadable), not an error: it is reported and not merged. pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Repo, args.Number) if err != nil { return nil, fmt.Errorf("read PR #%d: %w", args.Number, err) } // Gitea refuses closed and merged PRs itself; the gate does not depend on that. if pr.State != "open" || pr.Draft { reason := fmt.Sprintf("the PR is not open (state %q) — nothing to merge", pr.State) if pr.State == "open" { reason = "the PR is a draft — not merged" } return textOK(map[string]any{"merged": false, "pr_number": args.Number, "reason": reason}) } bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, pr.Base.Ref) if err != nil { return nil, fmt.Errorf("branch protection probe: %w", err) } runs := pollRuns(ctx, t.c, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec) risk := riskTierOf(ctx, t.c, args.Owner, args.Repo, args.Issue) gate := evaluateMergeGate(pr.Head.Sha, runs, bp, risk) result := map[string]any{ "merged": false, "pr_number": args.Number, "ci_status": gate.ciStatus, "risk": string(risk), } if !gate.merge { result["reason"] = gate.reason return textOK(result) } if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, args.Number, gitea.MergePRArgs{ Do: style, Title: args.Title, Body: args.Body, HeadCommitID: pr.Head.Sha, }); err != nil { return nil, err } result["merged"] = true return textOK(result) }