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
110 lines
4.3 KiB
Go
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)
|
|
}
|