feat(pr_merge): gate it like tbd_ship — green CI, unprotected base, exactly one risk/low issue (#74) (#76)
Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #76.
This commit is contained in:
+34
-20
@@ -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.
|
||||
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)
|
||||
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR left open, not merged", r)
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
return "" // never asked: only a non-low tier needs explaining
|
||||
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.
|
||||
if bp != nil && bp.Protected && bp.RequiredApprovals > 0 {
|
||||
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".
|
||||
@@ -189,14 +189,25 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
|
||||
case "success":
|
||||
return shipGate{true, ci, ""}
|
||||
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":
|
||||
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
|
||||
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"}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateMergeGate is what both merge tools call. It adds one precondition to evaluateShipGate: the PR's head commit
|
||||
// must be known. CI is read for that commit and the merge is bound to it (head_commit_id); with an empty sha the runs
|
||||
// query would lose its filter (the repo's most recent runs of ANY commit would be classified) and the binding would be
|
||||
// dropped, so an unknown head commit never merges.
|
||||
func evaluateMergeGate(headSHA string, runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
|
||||
if headSHA == "" {
|
||||
return shipGate{false, "none", "the PR's head commit is unknown — fail-closed (CI cannot be scoped and the merge cannot be bound to it); PR left open, not merged"}
|
||||
}
|
||||
return evaluateShipGate(runs, bp, risk)
|
||||
}
|
||||
|
||||
var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// deriveBranch builds a deterministic short-lived branch name from the change,
|
||||
@@ -302,9 +313,9 @@ 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)
|
||||
risk := t.riskTierOf(ctx, args.Owner, args.Repo, args.Issue)
|
||||
gate := evaluateShipGate(runs, bp, risk)
|
||||
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,
|
||||
@@ -320,9 +331,9 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
}
|
||||
|
||||
// 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", HeadCommitID: pr.Head.Sha}); err != nil {
|
||||
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 nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err)
|
||||
@@ -339,11 +350,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
|
||||
// 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 {
|
||||
func riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue int) riskTier {
|
||||
if issue <= 0 {
|
||||
return riskNone
|
||||
}
|
||||
iss, err := t.c.GetIssue(ctx, owner, repo, issue)
|
||||
iss, err := c.GetIssue(ctx, owner, repo, issue)
|
||||
if err != nil || iss == nil {
|
||||
return riskUnreadable
|
||||
}
|
||||
@@ -353,7 +364,10 @@ 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
|
||||
// them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
|
||||
// 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 headSHA == "" {
|
||||
return nil // never query without the commit filter: Gitea would return the repo's latest runs of any commit
|
||||
}
|
||||
if timeoutSec < 0 {
|
||||
timeoutSec = 0
|
||||
}
|
||||
@@ -362,7 +376,7 @@ func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, tim
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
for {
|
||||
runs := t.listRuns(ctx, owner, repo, headSHA)
|
||||
runs := listRuns(ctx, c, owner, repo, headSHA)
|
||||
switch classifyCI(runs) {
|
||||
case "success", "failed":
|
||||
return runs // terminal — no point waiting
|
||||
@@ -393,8 +407,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)
|
||||
}
|
||||
|
||||
func (t *TBDShip) listRuns(ctx context.Context, owner, repo, headSHA string) []gitea.WorkflowRun {
|
||||
resp, err := t.c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
|
||||
func listRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string) []gitea.WorkflowRun {
|
||||
resp, err := c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
|
||||
if err != nil || resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user