feat(pr_merge): gate it like tbd_ship — green CI, unprotected base, exactly one risk/low issue (gitea-mcp#74)
CD / Lint / Test / Vet (pull_request) Successful in 14s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped

pr_merge was an independent merge path with no CI, protection or tier check. It now reads the PR, the base's
branch protection and the head commit's CI runs, takes the tier from the issue named in `issue`, and runs the same
evaluateShipGate as tbd_ship (helpers shared, not copied). A refusal is a result (merged:false, reason, ci_status,
risk); an unreadable PR or protection probe is an error; a merge conflict is still an error. Above risk/low a human
merges in the UI. Tests: tier table, no-issue, issue lookup failures, CI and protection not overridden by risk/low,
read-side failures, tier read from the named issue only; nine wiring mutants flip-tested red. Shared reason text now
says "PR left open" (the PR already exists for pr_merge).

Behaviour change: pr_merge without `issue`, or on a PR with no green CI, merges nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
2026-10-08 08:11:40 +02:00
co-authored by Claude Sonnet 5.5
parent d3faffc4c4
commit c3d8401604
4 changed files with 340 additions and 79 deletions
+13 -6
View File
@@ -125,12 +125,19 @@ by area:
| Search / Templates | code_search, create_project_from_template | | Search / Templates | code_search, create_project_from_template |
| Composite | tbd_ship (trunk-based ship helper) | | Composite | tbd_ship (trunk-based ship helper) |
**`tbd_ship` merge gate (gitea-mcp#63).** A PR is squash-merged only when ALL of: the head commit's CI is fully green, **Merge gate: `tbd_ship` (gitea-mcp#63) and `pr_merge` (gitea-mcp#74).** The two merge tools share one gate
the base branch is not review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`. (`evaluateShipGate`). A PR is merged only when ALL of: the head commit's CI is fully green, the base branch is not
No `issue`, no `risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`. No `issue`, no
cannot be read all fail closed: the PR is opened, `merged:false`, with a `reason` and the `risk` tier in the result. `risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that cannot be read
Unclassified is not low. Limit: the gate enforces the tier of the issue the caller names; it cannot prove that issue is all fail closed: nothing is merged, the result is `merged:false` with a `reason`, the `ci_status` and the `risk` tier.
the one the change belongs to, so the link is the caller's claim (the convention in local-dev rule 5 still applies). Unclassified is not low. Above `risk/low` a human reviews and merges in the Gitea UI (which this gate does not touch).
`pr_merge` additionally fails with an error, merging nothing, when the PR or the base's branch-protection state cannot
be read. A merge conflict is still an error.
Limits: the gate enforces the tier of the issue the caller names; it cannot prove that issue is the one the change
belongs to, so the link is the caller's claim (local-dev rule 5's convention still applies). Not gated: merging in the
Gitea UI, and `file_write_branch` committing directly to a branch (`main` included), which is how `risk/low` work
reaches trunk. Platform-level enforcement for those is branch protection on `main` (local-dev#27 option 2).
Shared tool-layer hygiene (`internal/tools/tool.go`): Shared tool-layer hygiene (`internal/tools/tool.go`):
- **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so - **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so
+42 -10
View File
@@ -10,6 +10,9 @@ import (
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "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 { type PRMerge struct {
c *gitea.Client c *gitea.Client
a *allowlist.Allowlist a *allowlist.Allowlist
@@ -22,16 +25,18 @@ func NewPRMerge(c *gitea.Client, a *allowlist.Allowlist) *PRMerge {
func (t *PRMerge) Descriptor() registry.ToolDescriptor { func (t *PRMerge) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{ return registry.ToolDescriptor{
Name: "pr_merge", Name: "pr_merge",
Description: "Merge a pull request. style: merge (default), squash, or rebase.", 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(`{ InputSchema: json.RawMessage(`{
"type":"object", "type":"object",
"properties":{ "properties":{
"owner":{"type":"string"}, "owner":{"type":"string"},
"repo":{"type":"string"}, "repo":{"type":"string"},
"number":{"type":"integer","minimum":1}, "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"]}, "style":{"type":"string","enum":["merge","squash","rebase"]},
"merge_message_title":{"type":"string"}, "merge_message_title":{"type":"string"},
"merge_message_field":{"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"] "required":["owner","repo","number"]
}`), }`),
@@ -39,12 +44,14 @@ func (t *PRMerge) Descriptor() registry.ToolDescriptor {
} }
type prMergeArgs struct { type prMergeArgs struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Repo string `json:"repo"`
Number int `json:"number"` Number int `json:"number"`
Style string `json:"style"` Issue int `json:"issue"`
Title string `json:"merge_message_title"` Style string `json:"style"`
Body string `json:"merge_message_field"` 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) { func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
@@ -64,6 +71,31 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
style = "merge" 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{ if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, args.Number, gitea.MergePRArgs{
Do: style, Do: style,
Title: args.Title, Title: args.Title,
@@ -71,6 +103,6 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
result["merged"] = true
return textOK(map[string]any{"merged": true}) return textOK(result)
} }
+267 -45
View File
@@ -6,6 +6,8 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync"
"testing" "testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
@@ -15,80 +17,300 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// pr_merge is gated exactly like tbd_ship (gitea-mcp#74, #63): it merges only when the head commit's CI is fully
// green, the base is not review-protected AND the issue named in `issue` carries exactly one risk/ label, risk/low.
// The fake serves the whole read side the gate needs and records whether (and how) the merge endpoint was hit.
const (
greenRuns = `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"}]`
lowLabels = `[{"id":1,"name":"risk/low"}]`
)
type prMergeFakeOpts struct {
runs string // workflow_runs array for the head commit
issueLabels string // labels array of issue 42
issueStatus int
prStatus int // GET pulls/7
protectionStatus int // 0 → 404 (unprotected); 200 with required approvals when requiredApprovals>0
requiredApprovals int
mergeStatus int
}
type prMergeFake struct {
*httptest.Server
mu sync.Mutex
mergeCalls int
mergeBody []byte
}
func (f *prMergeFake) merged() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.mergeCalls > 0 }
func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
t.Helper()
if o.runs == "" {
o.runs = greenRuns
}
if o.issueLabels == "" {
o.issueLabels = lowLabels
}
if o.issueStatus == 0 {
o.issueStatus = http.StatusOK
}
if o.prStatus == 0 {
o.prStatus = http.StatusOK
}
if o.mergeStatus == 0 {
o.mergeStatus = http.StatusNoContent
}
f := &prMergeFake{}
f.Server = 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 && p == "/api/v1/repos/owner/repo/pulls/7":
w.WriteHeader(o.prStatus)
_, _ = w.Write([]byte(`{"number":7,"title":"t","state":"open","html_url":"http://x/pulls/7","head":{"ref":"feat","sha":"abc"},"base":{"ref":"main"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
switch {
case o.protectionStatus != 0:
w.WriteHeader(o.protectionStatus)
_, _ = w.Write([]byte(`{"message":"boom"}`))
case o.requiredApprovals > 0:
_, _ = w.Write([]byte(`{"required_approvals":` + itoa(o.requiredApprovals) + `}`))
default:
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + o.runs + `}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/owner/repo/issues/42":
w.WriteHeader(o.issueStatus)
_, _ = w.Write([]byte(`{"number":42,"title":"t","state":"open","labels":` + o.issueLabels + `}`))
case r.Method == http.MethodPost && p == "/api/v1/repos/owner/repo/pulls/7/merge":
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.mergeCalls++
f.mergeBody = body
f.mu.Unlock()
w.WriteHeader(o.mergeStatus)
_, _ = w.Write([]byte(`{"message":"merge conflict"}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, p)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.Close)
return f
}
func callMerge(t *testing.T, f *prMergeFake, args string) (map[string]any, error) {
t.Helper()
tool := tools.NewPRMerge(gitea.NewClient(f.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(args))
if err != nil {
return nil, err
}
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
return res, nil
}
const mergeArgs = `{"owner":"owner","repo":"repo","number":7,"issue":42}`
func TestPRMergeSuccess(t *testing.T) { func TestPRMergeSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f := newPRMergeFake(t, prMergeFakeOpts{})
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", r.URL.Path)
w.WriteHeader(http.StatusNoContent) res, err := callMerge(t, f, mergeArgs)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, true, res["merged"])
var result map[string]any assert.Equal(t, "success", res["ci_status"])
require.NoError(t, json.Unmarshal(out, &result)) assert.Equal(t, "low", res["risk"])
assert.Equal(t, true, result["merged"]) assert.Equal(t, float64(7), res["pr_number"])
assert.True(t, f.merged())
} }
func TestPRMergeDefaultsToMergeStyle(t *testing.T) { func TestPRMergeDefaultsToMergeStyleAndPassesStyleThrough(t *testing.T) {
var captured []byte f := newPRMergeFake(t, prMergeFakeOpts{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := callMerge(t, f, mergeArgs)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.NoError(t, err) require.NoError(t, err)
var payload map[string]any var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload)) require.NoError(t, json.Unmarshal(f.mergeBody, &payload))
assert.Equal(t, "merge", payload["Do"]) assert.Equal(t, "merge", payload["Do"])
f2 := newPRMergeFake(t, prMergeFakeOpts{})
_, err = callMerge(t, f2, `{"owner":"owner","repo":"repo","number":7,"issue":42,"style":"squash"}`)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(f2.mergeBody, &payload))
assert.Equal(t, "squash", payload["Do"])
} }
// A merge conflict is still an error, as before the gate existed.
func TestPRMergeConflictReturnsError(t *testing.T) { func TestPRMergeConflictReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f := newPRMergeFake(t, prMergeFakeOpts{mergeStatus: http.StatusConflict})
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"message":"merge conflict"}`)) _, err := callMerge(t, f, mergeArgs)
}))
defer srv.Close()
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"owner","name":"repo","index":7}`))
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict) assert.ErrorIs(t, err, gitea.ErrConflict)
assert.True(t, f.merged(), "the merge was attempted: the gate had passed")
} }
// #45: pr_merge advertises the canonical `number` (was `index`); `index` stays // #45: pr_merge advertises the canonical `number` (was `index`); `index` stays an accepted alias via the shim.
// an accepted alias via the shim.
func TestPRMergeNumberCanonical(t *testing.T) { func TestPRMergeNumberCanonical(t *testing.T) {
sch := string(tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor().InputSchema) sch := string(tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor().InputSchema)
assert.Contains(t, sch, `"number":`, "pr_merge must advertise number") assert.Contains(t, sch, `"number":`, "pr_merge must advertise number")
assert.NotContains(t, sch, `"index":`, "pr_merge must not advertise index") assert.NotContains(t, sch, `"index":`, "pr_merge must not advertise index")
for _, args := range []string{ for _, args := range []string{
`{"owner":"owner","repo":"repo","number":7}`, `{"owner":"owner","repo":"repo","number":7,"issue":42}`,
`{"owner":"owner","repo":"repo","index":7}`, `{"owner":"owner","repo":"repo","index":7,"issue":42}`,
`{"owner":"owner","name":"repo","index":7,"issue":42}`,
} { } {
var gotPath string f := newPRMergeFake(t, prMergeFakeOpts{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { res, err := callMerge(t, f, args)
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
tool := tools.NewPRMerge(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(args))
require.NoError(t, err, args) require.NoError(t, err, args)
assert.Equal(t, "/api/v1/repos/owner/repo/pulls/7/merge", gotPath, args) assert.Equal(t, true, res["merged"], args)
srv.Close()
} }
} }
// gitea-mcp#74: the descriptor tells the caller about the gate.
func TestPRMergeDescriptorAdvertisesTheGate(t *testing.T) {
d := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"})).Descriptor()
assert.Contains(t, string(d.InputSchema), `"issue":`)
assert.Contains(t, string(d.InputSchema), `"ci_timeout_seconds":`)
assert.Contains(t, d.Description, "risk/low")
assert.Contains(t, d.Description, "merged:false")
}
func TestPRMergeAllowlistRejects(t *testing.T) { func TestPRMergeAllowlistRejects(t *testing.T) {
tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) tool := tools.NewPRMerge(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`)) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","index":1}`))
require.Error(t, err) require.Error(t, err)
} }
// ---- the tier: only exactly one risk/low merges ----
func TestPRMerge_RiskTier_OnlyLowMerges(t *testing.T) {
tests := []struct {
name string
labels string
wantMerged bool
wantInReason string
}{
{"risk/low merges", `[{"id":1,"name":"risk/low"}]`, true, ""},
{"risk/low among other labels merges", `[{"id":2,"name":"type/fix"},{"id":1,"name":"risk/low"}]`, true, ""},
{"risk/medium does not merge", `[{"id":1,"name":"risk/medium"}]`, false, "risk/medium"},
{"risk/high does not merge", `[{"id":1,"name":"risk/high"}]`, false, "risk/high"},
{"no risk label is unclassified, not low", `[{"id":2,"name":"type/fix"}]`, false, "no risk/"},
{"no labels at all", `[]`, false, "no risk/"},
{"two risk labels are ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/high"}]`, false, "more than one"},
{"two identical risk/low labels are still ambiguous", `[{"id":1,"name":"risk/low"},{"id":2,"name":"risk/low"}]`, false, "more than one"},
{"an unknown value does not merge", `[{"id":1,"name":"risk/lowish"}]`, false, "unknown"},
{"the value is case-sensitive: risk/Low", `[{"id":1,"name":"risk/Low"}]`, false, "unknown"},
{"trailing space in the value", `[{"id":1,"name":"risk/low "}]`, false, "unknown"},
{"RISK/LOW is not a risk/ label", `[{"id":1,"name":"RISK/LOW"}]`, false, "no risk/"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{issueLabels: tc.labels})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err, "a refusal is a result, not an error")
assert.Equal(t, tc.wantMerged, res["merged"])
assert.Equal(t, tc.wantMerged, f.merged(), "the merge endpoint is called iff the tier allows it")
assert.Equal(t, "success", res["ci_status"], "CI was green: the tier alone decided")
if !tc.wantMerged {
assert.Contains(t, res["reason"], tc.wantInReason)
}
})
}
}
func TestPRMerge_NoIssue_Unclassified_NoMerge(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
res, err := callMerge(t, f, `{"owner":"owner","repo":"repo","number":7}`)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Equal(t, "none", res["risk"])
assert.Contains(t, res["reason"], "issue")
assert.False(t, f.merged(), "an unclassified change must not merge")
}
func TestPRMerge_IssueLookupFails_FailsClosed(t *testing.T) {
for _, status := range []int{http.StatusInternalServerError, http.StatusNotFound, http.StatusForbidden} {
f := newPRMergeFake(t, prMergeFakeOpts{issueStatus: status})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err, "status %d", status)
assert.Equal(t, false, res["merged"], "status %d", status)
assert.Equal(t, "unreadable", res["risk"], "status %d", status)
assert.False(t, f.merged(), "status %d: an unreadable issue must not merge", status)
}
}
// ---- risk/low is necessary, not sufficient: CI and branch protection still gate ----
func TestPRMerge_RiskLow_DoesNotOverrideCI(t *testing.T) {
for name, runs := range map[string]string{
"red": `[{"id":1,"status":"completed","conclusion":"failure","head_sha":"abc"}]`,
"cancelled": `[{"id":1,"status":"completed","conclusion":"cancelled","head_sha":"abc"}]`,
"in_progress": `[{"id":1,"status":"in_progress","conclusion":"","head_sha":"abc"}]`,
"no runs": `[]`,
"mixed": `[{"id":1,"status":"completed","conclusion":"success","head_sha":"abc"},{"id":2,"status":"queued","conclusion":"","head_sha":"abc"}]`,
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{runs: runs})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"])
assert.False(t, f.merged(), "risk/low must not override CI that is not fully green")
})
}
}
func TestPRMerge_RiskLow_DoesNotOverrideAReviewProtectedBase(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{requiredApprovals: 1})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "review")
assert.False(t, f.merged())
}
// An unreadable PR or an unreadable branch-protection probe is an error, never a merge.
func TestPRMerge_ReadSideFailures_AreErrorsAndNeverMerge(t *testing.T) {
for name, o := range map[string]prMergeFakeOpts{
"PR lookup 500": {prStatus: http.StatusInternalServerError},
"PR lookup 404": {prStatus: http.StatusNotFound},
"protection probe 500": {protectionStatus: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, o)
_, err := callMerge(t, f, mergeArgs)
require.Error(t, err)
assert.False(t, f.merged(), "must not merge when the gate cannot be evaluated")
})
}
}
// The classification comes from the issue the caller names, not from `number`: a PR number that is also an issue
// must not classify itself by accident. (The gate trusts the named issue: documented limit.)
func TestPRMerge_TheTierIsReadFromTheNamedIssueOnly(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{issueLabels: `[{"id":1,"name":"risk/high"}]`})
res, err := callMerge(t, f, `{"owner":"owner","repo":"repo","number":7,"issue":42}`)
require.NoError(t, err)
assert.Equal(t, "high", res["risk"])
assert.False(t, f.merged())
}
+18 -18
View File
@@ -117,19 +117,19 @@ func classifyRisk(labels []gitea.Label) riskTier {
} }
} }
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR opened, not merged" const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR left open, not merged"
// riskBlockReason explains why a tier other than riskLow blocks the merge. // riskBlockReason explains why a tier other than riskLow blocks the merge.
func riskBlockReason(r riskTier) string { func riskBlockReason(r riskTier) string {
switch r { switch r {
case riskMedium, riskHigh: case riskMedium, riskHigh:
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR opened, not merged", r) return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR left open, not merged", r)
case riskAmbiguous: case riskAmbiguous:
return "the issue carries more than one risk/ label — ambiguous classification; PR opened, not merged" return "the issue carries more than one risk/ label — ambiguous classification; PR left open, not merged"
case riskUnknown: case riskUnknown:
return "the issue carries an unknown risk/ value — PR opened, not merged" return "the issue carries an unknown risk/ value — PR left open, not merged"
case riskUnreadable: case riskUnreadable:
return "could not read the issue's labels — fail-closed; PR opened, not merged" return "could not read the issue's labels — fail-closed; PR left open, not merged"
case riskLow: case riskLow:
return "" // never asked: only a non-low tier needs explaining return "" // never asked: only a non-low tier needs explaining
case riskNone: case riskNone:
@@ -177,7 +177,7 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
// Review-protected base can't be auto-merged regardless of CI. // Review-protected base can't be auto-merged regardless of CI.
if bp != nil && bp.Protected && bp.RequiredApprovals > 0 { if bp != nil && bp.Protected && bp.RequiredApprovals > 0 {
return shipGate{false, ci, fmt.Sprintf( return shipGate{false, ci, fmt.Sprintf(
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)} "base branch requires %d approving review(s) — auto-merge disabled; PR left open for manual merge", bp.RequiredApprovals)}
} }
// Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI". // Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI".
@@ -189,11 +189,11 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
case "success": case "success":
return shipGate{true, ci, ""} return shipGate{true, ci, ""}
case "pending": case "pending":
return shipGate{false, ci, "CI is still running for the head commit — PR opened; re-invoke once checks complete, or merge manually"} return shipGate{false, ci, "CI is still running for the head commit — PR left open; re-invoke once checks complete, or merge manually"}
case "failed": case "failed":
return shipGate{false, ci, "CI failed for the head commit — PR opened, not merged"} return shipGate{false, ci, "CI failed for the head commit — PR left open, not merged"}
default: // none 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"} 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 left open for manual merge"}
} }
} }
@@ -302,8 +302,8 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
} }
// CI gate on the PR head commit. // CI gate on the PR head commit.
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec) runs := pollRuns(ctx, t.c, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
risk := t.riskTierOf(ctx, args.Owner, args.Repo, args.Issue) risk := riskTierOf(ctx, t.c, args.Owner, args.Repo, args.Issue)
gate := evaluateShipGate(runs, bp, risk) gate := evaluateShipGate(runs, bp, risk)
result := map[string]any{ result := map[string]any{
@@ -322,7 +322,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
// Green + unprotected → squash-merge, then delete the short-lived branch. // 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 err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash"}); err != nil {
if errors.Is(err, gitea.ErrConflict) { 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" result["reason"] = "base moved and the merge is not clean — PR left open, not merged; resolve the conflict and merge manually"
return textOK(result) return textOK(result)
} }
return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err) return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err)
@@ -339,11 +339,11 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
// riskTierOf classifies the change from the named issue. No issue, or an issue that cannot be read, never yields // riskTierOf classifies the change from the named issue. No issue, or an issue that cannot be read, never yields
// riskLow. The link between the PR and the issue is the caller's claim: the gate enforces the tier of the issue it // riskLow. The link between the PR and the issue is the caller's claim: the gate enforces the tier of the issue it
// is given, it cannot prove that issue is the one the change belongs to (stated limit, gitea-mcp#63). // is given, it cannot prove that issue is the one the change belongs to (stated limit, gitea-mcp#63).
func (t *TBDShip) riskTierOf(ctx context.Context, owner, repo string, issue int) riskTier { func riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue int) riskTier {
if issue <= 0 { if issue <= 0 {
return riskNone return riskNone
} }
iss, err := t.c.GetIssue(ctx, owner, repo, issue) iss, err := c.GetIssue(ctx, owner, repo, issue)
if err != nil || iss == nil { if err != nil || iss == nil {
return riskUnreadable return riskUnreadable
} }
@@ -353,7 +353,7 @@ func (t *TBDShip) riskTierOf(ctx context.Context, owner, repo string, issue int)
// pollRuns lists the head commit's workflow runs, polling up to timeoutSec for // 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 // them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
// sleep). A listing error yields no runs → the gate fails closed. // 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 { func pollRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun {
if timeoutSec < 0 { if timeoutSec < 0 {
timeoutSec = 0 timeoutSec = 0
} }
@@ -362,7 +362,7 @@ func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, tim
} }
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for { for {
runs := t.listRuns(ctx, owner, repo, headSHA) runs := listRuns(ctx, c, owner, repo, headSHA)
switch classifyCI(runs) { switch classifyCI(runs) {
case "success", "failed": case "success", "failed":
return runs // terminal — no point waiting return runs // terminal — no point waiting
@@ -393,8 +393,8 @@ func (t *TBDShip) findOpenPR(ctx context.Context, owner, repo, branch string) (*
return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound) return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound)
} }
func (t *TBDShip) listRuns(ctx context.Context, owner, repo, headSHA string) []gitea.WorkflowRun { func listRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string) []gitea.WorkflowRun {
resp, err := t.c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50}) resp, err := c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
if err != nil || resp == nil { if err != nil || resp == nil {
return nil return nil
} }