Files
gitea-mcp/internal/tools/pr_merge.go
T
mathiasandClaude Sonnet 5.5 6efd309159
CD / Lint / Test / Vet (pull_request) Successful in 12s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped
fix(merge gate): bind pr_merge and tbd_ship merges to the head commit the gate checked (TOCTOU)
The gate evaluates CI for one head sha; a push to the PR branch between that check and the merge would have merged
unchecked code. MergePRArgs now carries head_commit_id (documented in Gitea 1.27.1's MergePullRequestOption) and both
tools send the sha they evaluated. Tests for both tools; each removal flip-tested red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:12:45 +02:00

110 lines
4.3 KiB
Go

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)
}
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 := evaluateShipGate(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)
}