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
403 lines
15 KiB
Go
403 lines
15 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/identity"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
)
|
|
|
|
// TBDShip is the intent verb for the trunk-based loop: branch → write → PR →
|
|
// (CI-green) auto-merge → clean up. The safety is the CI gate — it fails closed
|
|
// on anything that isn't a fully-green, unprotected, cleanly-mergeable change.
|
|
type TBDShip struct {
|
|
c *gitea.Client
|
|
a *allowlist.Allowlist
|
|
}
|
|
|
|
func NewTBDShip(c *gitea.Client, a *allowlist.Allowlist) *TBDShip { return &TBDShip{c: c, a: a} }
|
|
|
|
const (
|
|
shipCIPollInterval = 5 * time.Second
|
|
shipCIMaxTimeoutSec = 600
|
|
)
|
|
|
|
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 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, risk, reason, branch}.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"owner":{"type":"string"},
|
|
"repo":{"type":"string"},
|
|
"path":{"type":"string","description":"File path to create or update."},
|
|
"content":{"type":"string","description":"Full file content (plain text)."},
|
|
"message":{"type":"string","description":"Commit message."},
|
|
"base":{"type":"string","description":"Base branch to ship to. Default 'main'."},
|
|
"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)."}
|
|
},
|
|
"required":["owner","repo","path","content","message"]
|
|
}`),
|
|
}
|
|
}
|
|
|
|
type tbdShipArgs struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Message string `json:"message"`
|
|
Base string `json:"base"`
|
|
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 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 left open, not merged", r)
|
|
case riskAmbiguous:
|
|
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 left open, not merged"
|
|
case riskUnreadable:
|
|
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:
|
|
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
|
|
ciStatus string // none | pending | failed | success
|
|
reason string // non-empty iff merge == false
|
|
}
|
|
|
|
// classifyCI reduces a set of workflow runs for one commit to a single status.
|
|
// Fail-closed: only "success" (all runs completed and successful) permits a
|
|
// merge; anything else — no runs, still-running, or any non-success conclusion
|
|
// (failure/cancelled/skipped) — blocks it.
|
|
func classifyCI(runs []gitea.WorkflowRun) string {
|
|
if len(runs) == 0 {
|
|
return "none"
|
|
}
|
|
for _, r := range runs {
|
|
if r.Status != "completed" {
|
|
return "pending"
|
|
}
|
|
}
|
|
for _, r := range runs {
|
|
if r.Conclusion != "success" {
|
|
return "failed"
|
|
}
|
|
}
|
|
return "success"
|
|
}
|
|
|
|
// 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 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.
|
|
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 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".
|
|
if risk != riskLow {
|
|
return shipGate{false, ci, riskBlockReason(risk)}
|
|
}
|
|
|
|
switch ci {
|
|
case "success":
|
|
return shipGate{true, ci, ""}
|
|
case "pending":
|
|
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 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 left open for manual merge"}
|
|
}
|
|
}
|
|
|
|
var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
// deriveBranch builds a deterministic short-lived branch name from the change,
|
|
// so the same change maps to the same branch and distinct changes don't collide.
|
|
func deriveBranch(message, path, content string) string {
|
|
slug := strings.Trim(shipSlugRe.ReplaceAllString(strings.ToLower(message), "-"), "-")
|
|
if len(slug) > 32 {
|
|
slug = strings.Trim(slug[:32], "-")
|
|
}
|
|
if slug == "" {
|
|
slug = "change"
|
|
}
|
|
sum := sha256.Sum256([]byte(path + "\x00" + content + "\x00" + message))
|
|
return "tbd/" + slug + "-" + hex.EncodeToString(sum[:])[:8]
|
|
}
|
|
|
|
func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
var args tbdShipArgs
|
|
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.Path == "" || args.Content == "" || args.Message == "" {
|
|
return nil, fmt.Errorf("path, content, and message are required: %w", gitea.ErrValidation)
|
|
}
|
|
|
|
base := args.Base
|
|
if base == "" {
|
|
base = "main"
|
|
}
|
|
branch := args.Branch
|
|
if branch == "" {
|
|
branch = deriveBranch(args.Message, args.Path, args.Content)
|
|
}
|
|
// Both names reach a ref: base is also the protection-probe key, so a
|
|
// `refs/heads/main` base would read as unprotected (#65).
|
|
for _, b := range []string{base, branch} {
|
|
if err := ValidateBranchName(b); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
deleteBranch := true
|
|
if args.DeleteBranch != nil {
|
|
deleteBranch = *args.DeleteBranch
|
|
}
|
|
|
|
// Probe base-branch protection up front — a real error fails closed.
|
|
bp, err := t.c.GetBranchProtection(ctx, args.Owner, args.Repo, base)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("branch protection probe: %w", err)
|
|
}
|
|
|
|
// Branch from base. A conflict means the branch already exists — resume on it
|
|
// (idempotent re-invoke, #48) rather than failing.
|
|
if err := t.c.CreateBranch(ctx, args.Owner, args.Repo, branch, base); err != nil && !errors.Is(err, gitea.ErrConflict) {
|
|
return nil, fmt.Errorf("create branch %s: %w", branch, err)
|
|
}
|
|
|
|
// A pre-existing file at path needs its blob sha to update; a new file does
|
|
// not. If the content already on the branch is identical, skip the write so a
|
|
// resume doesn't produce a redundant empty-diff commit.
|
|
sha := ""
|
|
needWrite := true
|
|
if fc, ferr := t.c.GetFileContents(ctx, args.Owner, args.Repo, args.Path, branch); ferr == nil {
|
|
sha = fc.Sha
|
|
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == args.Content {
|
|
needWrite = false
|
|
}
|
|
} else if !errors.Is(ferr, gitea.ErrNotFound) {
|
|
return nil, fmt.Errorf("read %s on %s: %w", args.Path, branch, ferr)
|
|
}
|
|
if needWrite {
|
|
if _, err := t.c.UpsertFile(ctx, args.Owner, args.Repo, args.Path, gitea.UpsertFileArgs{
|
|
Branch: branch,
|
|
Content: base64.StdEncoding.EncodeToString([]byte(args.Content)),
|
|
Message: args.Message,
|
|
Sha: sha,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("write %s on %s: %w", args.Path, branch, err)
|
|
}
|
|
}
|
|
|
|
prTitle := args.PRTitle
|
|
if prTitle == "" {
|
|
prTitle = args.Message
|
|
}
|
|
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Repo, gitea.CreatePullRequestArgs{
|
|
Title: prTitle,
|
|
Body: identity.ApplyFooter(args.PRBody, auth.Caller(ctx)),
|
|
Head: branch,
|
|
Base: base,
|
|
})
|
|
if err != nil {
|
|
// An open PR for this head already exists → resume it (#48).
|
|
if errors.Is(err, gitea.ErrConflict) || errors.Is(err, gitea.ErrValidation) {
|
|
pr, err = t.findOpenPR(ctx, args.Owner, args.Repo, branch)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open PR: %w", err)
|
|
}
|
|
}
|
|
|
|
// CI gate on the PR head commit.
|
|
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": pr.Number,
|
|
"pr_url": pr.HTMLURL,
|
|
"branch": branch,
|
|
"ci_status": gate.ciStatus,
|
|
"risk": string(risk),
|
|
}
|
|
if !gate.merge {
|
|
result["reason"] = gate.reason
|
|
return textOK(result)
|
|
}
|
|
|
|
// 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 errors.Is(err, gitea.ErrConflict) {
|
|
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)
|
|
}
|
|
result["merged"] = true
|
|
if deleteBranch {
|
|
if derr := t.c.DeleteBranch(ctx, args.Owner, args.Repo, branch); derr == nil {
|
|
result["branch_deleted"] = true
|
|
}
|
|
}
|
|
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 riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue int) riskTier {
|
|
if issue <= 0 {
|
|
return riskNone
|
|
}
|
|
iss, err := 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.
|
|
func pollRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun {
|
|
if timeoutSec < 0 {
|
|
timeoutSec = 0
|
|
}
|
|
if timeoutSec > shipCIMaxTimeoutSec {
|
|
timeoutSec = shipCIMaxTimeoutSec
|
|
}
|
|
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
|
for {
|
|
runs := listRuns(ctx, c, owner, repo, headSHA)
|
|
switch classifyCI(runs) {
|
|
case "success", "failed":
|
|
return runs // terminal — no point waiting
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return runs // out of time — return whatever we have (none/pending)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return runs
|
|
case <-time.After(shipCIPollInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
// findOpenPR returns the open PR whose head is the given branch — used to resume
|
|
// an existing PR when CreatePullRequest reports one already exists (#48).
|
|
func (t *TBDShip) findOpenPR(ctx context.Context, owner, repo, branch string) (*gitea.PullRequest, error) {
|
|
prs, err := t.c.ListPullRequests(ctx, owner, repo, "open", branch, 1, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range prs {
|
|
if prs[i].Head.Ref == branch {
|
|
return &prs[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound)
|
|
}
|
|
|
|
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
|
|
}
|
|
return resp.WorkflowRuns
|
|
}
|