feat(tbd_ship): gate auto-merge on the risk tier; unclassified is not low (gitea-mcp#63)
tbd_ship takes an optional `issue`; the PR is squash-merged only when CI is fully green, the base is not review-protected AND the issue carries exactly one risk/ label, risk/low. No issue, no label, several labels, an unknown value, risk/medium, risk/high or an unreadable issue all fail closed to PR-only with a reason, and the result reports the tier. The zero tier is not low. Tests: gate table incl. every non-low tier, classifyRisk table, end-to-end cases (green+medium, lookup 500/404/403, label case, containment), nine mutants flip-tested red. Limit: the gate enforces the tier of the issue the caller names; it cannot prove it is the right issue. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
@@ -37,7 +37,7 @@ const (
|
||||
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}.",
|
||||
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 AND the named issue carries exactly one risk/ label, risk/low. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when no issue is named or its risk/ label is missing, ambiguous, unknown, risk/medium or risk/high, when the issue cannot be read, 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":{
|
||||
@@ -50,6 +50,7 @@ func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
||||
"branch":{"type":"string","description":"Short-lived branch name. Default derived: tbd/<slug>-<hash>."},
|
||||
"pr_title":{"type":"string","description":"PR title. Default: the commit message."},
|
||||
"pr_body":{"type":"string"},
|
||||
"issue":{"type":"integer","minimum":1,"description":"Issue number in the same repo whose risk/ label classifies this change. Auto-merge happens only for exactly one label, risk/low; no issue, none, several, an unknown value, risk/medium or risk/high means PR only, never merged (unclassified is not low)."},
|
||||
"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)."}
|
||||
},
|
||||
@@ -68,10 +69,76 @@ type tbdShipArgs struct {
|
||||
Branch string `json:"branch"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRBody string `json:"pr_body"`
|
||||
Issue int `json:"issue"`
|
||||
DeleteBranch *bool `json:"delete_branch"`
|
||||
CITimeoutSec int `json:"ci_timeout_seconds"`
|
||||
}
|
||||
|
||||
// riskTier is the change's classification under the lane model (local-dev .context/AGENT.md rule 5): only a change
|
||||
// whose issue carries exactly one risk/ label, risk/low, may merge on green; everything else is reviewed by a human
|
||||
// (gitea-mcp#63). The zero value is deliberately NOT riskLow: a forgotten field must never mean "low".
|
||||
type riskTier string
|
||||
|
||||
const (
|
||||
riskLow riskTier = "low"
|
||||
riskMedium riskTier = "medium"
|
||||
riskHigh riskTier = "high"
|
||||
riskNone riskTier = "none" // no issue named, or the issue has no risk/ label: unclassified is not low
|
||||
riskAmbiguous riskTier = "ambiguous" // more than one risk/ label
|
||||
riskUnknown riskTier = "unknown" // risk/<something else>
|
||||
riskUnreadable riskTier = "unreadable" // the issue lookup failed
|
||||
)
|
||||
|
||||
// classifyRisk reads the tier from an issue's labels. Exact, case-sensitive prefix "risk/"; exactly one such label
|
||||
// is required, so two labels (even identical ones) are ambiguous rather than "the lower one wins".
|
||||
func classifyRisk(labels []gitea.Label) riskTier {
|
||||
var found []string
|
||||
for _, l := range labels {
|
||||
if strings.HasPrefix(l.Name, "risk/") {
|
||||
found = append(found, strings.TrimPrefix(l.Name, "risk/"))
|
||||
}
|
||||
}
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return riskNone
|
||||
case 1:
|
||||
default:
|
||||
return riskAmbiguous
|
||||
}
|
||||
switch found[0] {
|
||||
case "low":
|
||||
return riskLow
|
||||
case "medium":
|
||||
return riskMedium
|
||||
case "high":
|
||||
return riskHigh
|
||||
default:
|
||||
return riskUnknown
|
||||
}
|
||||
}
|
||||
|
||||
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR opened, not merged"
|
||||
|
||||
// riskBlockReason explains why a tier other than riskLow blocks the merge.
|
||||
func riskBlockReason(r riskTier) string {
|
||||
switch r {
|
||||
case riskMedium, riskHigh:
|
||||
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR opened, not merged", r)
|
||||
case riskAmbiguous:
|
||||
return "the issue carries more than one risk/ label — ambiguous classification; PR opened, not merged"
|
||||
case riskUnknown:
|
||||
return "the issue carries an unknown risk/ value — PR opened, not merged"
|
||||
case riskUnreadable:
|
||||
return "could not read the issue's labels — fail-closed; PR opened, not merged"
|
||||
case riskLow:
|
||||
return "" // never asked: only a non-low tier needs explaining
|
||||
case riskNone:
|
||||
return unclassifiedReason
|
||||
default: // the zero value and any tier added later: unclassified is not low
|
||||
return unclassifiedReason
|
||||
}
|
||||
}
|
||||
|
||||
// shipGate is the decision produced by evaluateShipGate.
|
||||
type shipGate struct {
|
||||
merge bool
|
||||
@@ -101,9 +168,10 @@ func classifyCI(runs []gitea.WorkflowRun) string {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// only when CI is fully green AND the base branch is not review-protected AND the
|
||||
// change is classified risk/low. Every other state fails closed to PR-only with an
|
||||
// explanatory reason (#40, gitea-mcp#63).
|
||||
func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
|
||||
ci := classifyCI(runs)
|
||||
|
||||
// Review-protected base can't be auto-merged regardless of CI.
|
||||
@@ -112,6 +180,11 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection) ship
|
||||
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)}
|
||||
}
|
||||
|
||||
// Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI".
|
||||
if risk != riskLow {
|
||||
return shipGate{false, ci, riskBlockReason(risk)}
|
||||
}
|
||||
|
||||
switch ci {
|
||||
case "success":
|
||||
return shipGate{true, ci, ""}
|
||||
@@ -230,7 +303,8 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
|
||||
// CI gate on the PR head commit.
|
||||
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
|
||||
gate := evaluateShipGate(runs, bp)
|
||||
risk := t.riskTierOf(ctx, args.Owner, args.Repo, args.Issue)
|
||||
gate := evaluateShipGate(runs, bp, risk)
|
||||
|
||||
result := map[string]any{
|
||||
"merged": false,
|
||||
@@ -238,6 +312,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
"pr_url": pr.HTMLURL,
|
||||
"branch": branch,
|
||||
"ci_status": gate.ciStatus,
|
||||
"risk": string(risk),
|
||||
}
|
||||
if !gate.merge {
|
||||
result["reason"] = gate.reason
|
||||
@@ -261,6 +336,20 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
return textOK(result)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
if issue <= 0 {
|
||||
return riskNone
|
||||
}
|
||||
iss, err := t.c.GetIssue(ctx, owner, repo, issue)
|
||||
if err != nil || iss == nil {
|
||||
return riskUnreadable
|
||||
}
|
||||
return classifyRisk(iss.Labels)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user