From 0d69dd4ed3445077ac13bc22d4b58577a65bf277 Mon Sep 17 00:00:00 2001 From: thatguywob Date: Wed, 28 Oct 2026 03:51:05 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20preserve=20supersessi?= =?UTF-8?q?on=20decisions=20across=20stage=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep a supersession cause label separate from CI and author repair state, retain escalation evidence in tracker audit replay, and expose the closer and keep-open reason even when a stale record still says 071/072. Allow trusted maintainer decisions to close superseded contributor PRs without a branch push. Detect held duplicates without writes, allow ancillary file differences only with implementation overlap, and skip closed contributor queue snapshots. Hive-Run: hivecommons/hive#14814 Hive-Plan: issue-14814-supersession-sweep-and-stage-tracker Hive-Spec: issue-14814#fleet-generic-fix Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> Signed-off-by: thatguywob --- changelog.d/fixed-14814-supersession-stage.md | 1 + src/cmd/hive/main.go | 4 +- src/docs/maintainer-commands.md | 11 ++ .../dashboard/api_review_queue_superseded.go | 8 +- .../api_review_queue_superseded_test.go | 2 + src/pkg/dashboard/stage_tracker.go | 34 +++- .../stage_tracker_supersession_test.go | 62 +++++++ src/pkg/dashboard/static/index.html | 4 +- src/pkg/dashboard/worksource_identity.go | 4 + .../automerge/automerge_stage_labels.go | 4 +- .../automerge_stage_supersession_test.go | 37 +++++ src/pkg/github/pr_supersession_autoclose.go | 14 +- .../github/pr_supersession_autoclose_test.go | 2 +- src/pkg/github/pr_supersession_decision.go | 57 +++++++ .../github/pr_supersession_decision_test.go | 151 ++++++++++++++++++ src/pkg/github/pr_supersession_resolve.go | 73 +++++++-- .../github/pr_supersession_resolve_test.go | 4 +- src/pkg/github/pr_supersession_sweep.go | 43 +++-- src/pkg/github/pr_supersession_sweep_test.go | 7 +- src/pkg/github/pr_unpark_decision.go | 12 ++ src/pkg/github/review_queue.go | 1 + src/pkg/stageengine/derive_pr.go | 4 +- src/pkg/stageengine/derive_pr_classify.go | 3 + .../derive_pr_supersession_test.go | 38 +++++ src/pkg/stageengine/prstage/reasons.go | 5 + 25 files changed, 534 insertions(+), 51 deletions(-) create mode 100644 changelog.d/fixed-14814-supersession-stage.md create mode 100644 src/pkg/dashboard/stage_tracker_supersession_test.go create mode 100644 src/pkg/github/automerge/automerge_stage_supersession_test.go create mode 100644 src/pkg/github/pr_supersession_decision.go create mode 100644 src/pkg/github/pr_supersession_decision_test.go create mode 100644 src/pkg/stageengine/derive_pr_supersession_test.go diff --git a/changelog.d/fixed-14814-supersession-stage.md b/changelog.d/fixed-14814-supersession-stage.md new file mode 100644 index 0000000000..6d967b525e --- /dev/null +++ b/changelog.d/fixed-14814-supersession-stage.md @@ -0,0 +1 @@ +- Keep superseded contributor PRs at the decision stage despite CI or rebase failures, show the closing PR and keep-open reason in the stage tracker, and let trusted maintainers close them with `/hive decision reject` or `/hive decision close superseded by #N`. Supplemental tests, docs and changelog files no longer block duplicate recommendations when implementation files overlap; held PRs remain visible without sweep writes, and closed issue snapshots cannot be offered as contributor work. diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index d858649305..b3f7debeb5 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -9530,8 +9530,8 @@ func runSupersessionSweepIfDue(ctx context.Context, ghClient *github.Client, cfg if dashSrv == nil { return } - detail := fmt.Sprintf("repo=%s, pr=%d, issue=%s#%d, closer_pr=%d, action=%s", - event.Repo, event.Number, event.IssueRepo, event.Issue, event.CloserPR, event.Action) + detail := fmt.Sprintf("repo=%s, pr=%d, issue=%s#%d, closer_pr=%d, action=%s, reason=%s", + event.Repo, event.Number, event.IssueRepo, event.Issue, event.CloserPR, event.Action, event.Reason) dashSrv.AuditLogRecord("system", github.SupersessionSweepAuditPrefix+event.Action, detail, "", event.Repo, event.Number) }, }) diff --git a/src/docs/maintainer-commands.md b/src/docs/maintainer-commands.md index aa05348fe6..2945054b4c 100644 --- a/src/docs/maintainer-commands.md +++ b/src/docs/maintainer-commands.md @@ -59,6 +59,17 @@ specific to that queue flow. A swarm `changes_requested` or `reject` verdict is not overridden by a human approval; the head must be fixed or merged manually. +For a PR whose closing claims were superseded by another merged PR, a trusted +maintainer with repository write access can reply `/hive decision reject` or +`/hive decision close superseded by #N`. Hive rechecks that the claimed issues +are closed and a different merged PR closed them; an explicit `#N` must match +that closer. It comments and closes the PR with `hive/superseded`, including +PRs opened by contributors, without pushing to their branches. Hold and +`do-not-merge` gates still prevent the close. The sweep's +`hive/supersession-pending` cause label keeps the unresolved decision at 085 +through CI or rebase failures; reopening the issue, rewriting the claim, or +choosing `hive/keep-open` clears the sweep's park on its next pass. + On a **hive-authored** PR (`author/hive`) parked at `stage/085-needs-decision` the same trusted human has a second lever ([#13041](https://github.com/hivecommons/hive/issues/13041)): a GitHub diff --git a/src/pkg/dashboard/api_review_queue_superseded.go b/src/pkg/dashboard/api_review_queue_superseded.go index 97d68ea39d..e81d552255 100644 --- a/src/pkg/dashboard/api_review_queue_superseded.go +++ b/src/pkg/dashboard/api_review_queue_superseded.go @@ -79,7 +79,7 @@ func supersededGraceStates(entries []AuditEntry, org string, grace time.Duration } key := ghpkg.ReviewQueueKey(repo, e.Target) switch e.Action { - case supersessionDetectedAuditAction, supersessionGraceAuditAction: + case supersessionDetectedAuditAction, supersessionGraceAuditAction, ghpkg.SupersessionSweepAuditPrefix + ghpkg.SupersessionActionEscalated: case supersessionCommentedAuditAction: if detected[key] { info := out[key] @@ -104,6 +104,7 @@ func supersededGraceStates(entries []AuditEntry, org string, grace time.Duration } info := ghpkg.ReviewQueueSuperseded{ Reason: ghpkg.ReviewQueueSupersededReason, + KeepReason: fields["reason"], Issue: fields["issue"], CloserRepo: repo, CloserPR: closerPR, @@ -117,7 +118,10 @@ func supersededGraceStates(entries []AuditEntry, org string, grace time.Duration if previous, ok := out[key]; ok && previous.CloserRepo == info.CloserRepo && previous.CloserPR == info.CloserPR && previous.Issue == info.Issue { info.GraceStartedAt, info.GraceEndsAt = previous.GraceStartedAt, previous.GraceEndsAt } - if e.Action == supersessionDetectedAuditAction { + if e.Action != supersessionGraceAuditAction { + if e.Action == ghpkg.SupersessionSweepAuditPrefix+ghpkg.SupersessionActionEscalated { + info.GraceStartedAt, info.GraceEndsAt = time.Time{}, time.Time{} + } detected[key] = true } else if info.GraceStartedAt.IsZero() { info.GraceStartedAt, info.GraceEndsAt = at.UTC(), at.Add(grace).UTC() diff --git a/src/pkg/dashboard/api_review_queue_superseded_test.go b/src/pkg/dashboard/api_review_queue_superseded_test.go index 45adeb9af7..39bafef0c8 100644 --- a/src/pkg/dashboard/api_review_queue_superseded_test.go +++ b/src/pkg/dashboard/api_review_queue_superseded_test.go @@ -325,6 +325,8 @@ func TestSupersededDetectionReplay(t *testing.T) { start time.Time }{ {"detection only", []AuditEntry{detection}, true, time.Time{}}, + {"escalation retains decision", []AuditEntry{detection, entry("supersession-sweep-commented-needs-human", now)}, true, time.Time{}}, + {"escalation cancels grace", []AuditEntry{detection, grace, entry("supersession-sweep-commented-needs-human", now)}, true, time.Time{}}, {"plain notice", []AuditEntry{detection, notice, repeat}, true, time.Time{}}, {"grace starts at notice", []AuditEntry{detection, grace}, true, now.Add(time.Hour)}, {"repeated detection preserves grace", []AuditEntry{detection, grace, repeat, entry(supersessionGraceAuditAction, now.Add(4*time.Hour))}, true, now.Add(time.Hour)}, diff --git a/src/pkg/dashboard/stage_tracker.go b/src/pkg/dashboard/stage_tracker.go index 6e52fc82e7..3a3309a1fc 100644 --- a/src/pkg/dashboard/stage_tracker.go +++ b/src/pkg/dashboard/stage_tracker.go @@ -213,12 +213,13 @@ type StageTrackerHop struct { // StageTrackerItem is one open issue or PR on the tracker. type StageTrackerItem struct { - Repo string `json:"repo"` - Number int `json:"number"` - Kind string `json:"kind"` - Title string `json:"title"` - URL string `json:"url,omitempty"` - Author string `json:"author,omitempty"` + Superseded *github.ReviewQueueSuperseded `json:"superseded,omitempty"` + Repo string `json:"repo"` + Number int `json:"number"` + Kind string `json:"kind"` + Title string `json:"title"` + URL string `json:"url,omitempty"` + Author string `json:"author,omitempty"` // Head is the PR head SHA the lane last evaluated ("" for issues), so // a "waiting for a verdict" caption can name the revision it waits on. Head string `json:"head,omitempty"` @@ -1304,6 +1305,15 @@ func (s *Server) buildStageTracker(status *StatusPayload, store *stagelabels.Sto return resp } resp.HiveID = status.HiveID + qualifyRepo := stageTrackerRepoQualifier(status) + superseded := map[string]github.ReviewQueueSuperseded{} + entries := append([]AuditEntry(nil), s.supersessionAuditEntries(now)...) + for i := range entries { + entries[i].Repo = qualifyRepo(entries[i].Repo) + } + for key, info := range supersededGraceStates(entries, "", s.supersessionGracePeriod()) { + superseded[key] = info + } var cfg *config.Config if s.deps != nil { cfg = s.deps.Config @@ -1496,6 +1506,18 @@ func (s *Server) buildStageTracker(status *StatusPayload, store *stagelabels.Sto it.DeferredModifiers = append([]string{modSentinel}, it.DeferredModifiers...) } it.LinkedIssues = stageTrackerPRLinkedIssues(pr, repoName, status.GitHubBaseURL) + info, found := superseded[github.ReviewQueueKey(qualifyRepo(repoName), pr.Number)] + if !found { + info, found = superseded[github.ReviewQueueKey(repoName, pr.Number)] + } + if found && !labelSet(pr.Labels)[github.SupersessionKeepOpenLabel] { + it.Superseded = &info + it.Caption = fmt.Sprintf("Superseded by #%d — decide", info.CloserPR) + if info.KeepReason != "" { + it.Caption += ": " + info.KeepReason + } + it.Actor = "maintainer" + } add(it) } for _, repo := range status.Repos { diff --git a/src/pkg/dashboard/stage_tracker_supersession_test.go b/src/pkg/dashboard/stage_tracker_supersession_test.go new file mode 100644 index 0000000000..b84f4f485b --- /dev/null +++ b/src/pkg/dashboard/stage_tracker_supersession_test.go @@ -0,0 +1,62 @@ +package dashboard + +import ( + "strings" + "testing" + "time" + + "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/worksource" +) + +func TestTrackerSupersessionSurvivesEscalationAndStaleRepairStage(t *testing.T) { + useRingSupersessionAudit(t) + s := newTestServer() + now := time.Now().UTC() + status := minimalPayload() + status.Repos = []FrontendRepo{{Name: "hive", Full: "hivecommons/hive", OpenPrs: []any{ + FrontendPR{PullRequest: github.PullRequest{Number: 14795, Title: "duplicate", Author: "contributor", Labels: []string{"stage/072-returned-to-contributor", "sentinel-alert"}, LinkedIssues: []github.PRLinkedIssue{{Number: 14789, State: "closed"}}}}, + }}} + detail := "repo=hive, pr=14795, issue=hivecommons/hive#14789, closer_pr=14791, reason=extra implementation files" + s.audit.LogRecordAt(now.Add(-time.Minute).Format(time.RFC3339), "system", supersessionDetectedAuditAction, detail, "", "hive", 14795) + s.audit.LogRecordAt(now.Format(time.RFC3339), "system", github.SupersessionSweepAuditPrefix+github.SupersessionActionEscalated, detail, "", "hive", 14795) + store := stagelabels.NewStore("", nil) + store.Observe(stagelabels.Key("hivecommons/hive", 14795), stagelabels.ReturnedToContributor, "check-failing:unit") + resp := s.buildStageTracker(status, store, now) + if len(resp.Items) != 1 { + t.Fatalf("items=%+v", resp.Items) + } + it := resp.Items[0] + if it.Superseded == nil || it.Superseded.CloserPR != 14791 || !strings.Contains(it.Caption, "Superseded by #14791 — decide") || !strings.Contains(it.Caption, "extra implementation files") { + t.Fatalf("supersession lost to repair stage: %+v", it) + } + if it.Actor != "maintainer" { + t.Fatal(it.Actor) + } + s.audit.LogRecord("owner", supersessionKeepOpenAction, "", "", "hive", 14795) + if it := s.buildStageTracker(status, store, now).Items[0]; it.Superseded != nil { + t.Fatalf("kept-open still needs decision: %+v", it) + } +} + +func TestClosedSnapshotIsNeverActionable(t *testing.T) { + raw := []any{map[string]any{"number": 1, "state": "closed"}, map[string]any{"number": 2, "state": "open"}} + var offered []string + forEachActionableIssue(nil, "test", "o/r", raw, func(_ map[string]any, ref worksource.Ref) { offered = append(offered, ref.Key()) }) + if len(offered) != 1 || offered[0] != "o/r#2" { + t.Fatalf("offered=%v", offered) + } +} + +func TestSupersededRepairRowHasDecisionActionAndHeadline(t *testing.T) { + html := indexHTML(t) + script := stageTrackerRowActionsPreamble(t, html) + "\n" + jsFunc(t, html, "stageTrackerHeadline") + "\n" + jsFunc(t, html, "stageTrackerCurrentStageHtml") + ` + const item = {kind:'pr',repo:'o/r',number:12,stage:72,superseded:{closer_pr:22}}; + assert.strictEqual(stageTrackerHeadline(item), 'Superseded by #22 — decide'); + assert.ok(stageTrackerCurrentStageHtml(item, [], null, 0).includes('Superseded by #22')); + assert.strictEqual(stageTrackerDecisionTarget(item), item); + assert.ok(stageTrackerRowActions(item, {canHold:true}).some(a => a.id === 'decide')); + ` + runStageTrackerRowActionsScript(t, script) +} diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index c81e5bd0c2..5cddbd008d 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -13927,6 +13927,7 @@

Where to get help

var _stageTrackerLabelQuery = ''; function stageTrackerHeadline(item) { + if (item.superseded && item.superseded.closer_pr) return 'Superseded by #' + item.superseded.closer_pr + ' — decide'; const n = Number(item.stage) || 0; const mods = Array.isArray(item.modifiers) ? item.modifiers : []; const holdLabels = stageTrackerHoldLabels(item); @@ -14190,6 +14191,7 @@

Where to get help

// Compact strips retain their column alignment; the current step gets a // full-width caption so detours and the next actor never need a hover. function stageTrackerCurrentStageHtml(item, stages, range, nowMs) { + if (item.superseded && item.superseded.closer_pr) return `

${esc(stageTrackerHeadline(item) + (item.superseded.keep_reason ? " · " + item.superseded.keep_reason : ""))}

`; const active = stageTrackerSegments(item, stages, nowMs).find(sg => sg.state === 'active' && (!range || range.from == null || sg.number >= range.from) && (!range || range.to == null || sg.number <= range.to)); @@ -15828,7 +15830,7 @@

All labels

function stageTrackerDecisionTarget(item) { if (!item) return null; const mods = Array.isArray(item.modifiers) ? item.modifiers : []; - const parked = it => (Array.isArray(it.modifiers) ? it.modifiers : []).indexOf('needs-decision') >= 0 || STAGE_TRACKER_DECISION_STAGES.indexOf(Number(it.stage) || 0) >= 0; + const parked = it => !!it.superseded || (Array.isArray(it.modifiers) ? it.modifiers : []).indexOf('needs-decision') >= 0 || STAGE_TRACKER_DECISION_STAGES.indexOf(Number(it.stage) || 0) >= 0; if (item.kind === 'pr') return parked(item) ? item : null; for (const lp of (Array.isArray(item.linked_prs) ? item.linked_prs : [])) { const pr = { repo: item.repo, number: lp.number, kind: 'pr', title: lp.title || '', url: lp.url || '', stage: lp.stage || 0, modifiers: lp.modifiers || [], deferred_modifiers: lp.deferred_modifiers || [], author: lp.author || '', diff --git a/src/pkg/dashboard/worksource_identity.go b/src/pkg/dashboard/worksource_identity.go index 6b262248e8..33a9fb57c7 100644 --- a/src/pkg/dashboard/worksource_identity.go +++ b/src/pkg/dashboard/worksource_identity.go @@ -3,6 +3,7 @@ package dashboard import ( "encoding/json" "log/slog" + "strings" ghpkg "github.com/hivecommons/hive/pkg/github" "github.com/hivecommons/hive/pkg/worksource" @@ -104,6 +105,9 @@ func forEachActionableIssue(logger *slog.Logger, logTag string, repoFull string, } continue } + if state, _ := issue["state"].(string); strings.EqualFold(state, "closed") { + continue + } ref := refFromIssueMap(repoFull, issue) if ref.Key() == "" { if logger != nil { diff --git a/src/pkg/github/automerge/automerge_stage_labels.go b/src/pkg/github/automerge/automerge_stage_labels.go index 6e687c20b3..bf6a602bae 100644 --- a/src/pkg/github/automerge/automerge_stage_labels.go +++ b/src/pkg/github/automerge/automerge_stage_labels.go @@ -807,7 +807,7 @@ func (c *Engine) ApplyStageLabels(ctx context.Context) (StageLabelResult, error) obs.Reason = replacement.Reason } var route stagelabels.Route - if verdict.Human && !verdict.Terminal { + if verdict.Human && !verdict.Terminal && verdict.Reason != prstage.ReasonSupersessionPending { var evidence stagelabels.Evidence route, evidence = c.routeHuman(ctx, tracker, key, obs, verdict.Stage, now) pass.Route = &evidence @@ -824,7 +824,7 @@ func (c *Engine) ApplyStageLabels(ctx context.Context) (StageLabelResult, error) // the label state, which is reset below for informational no-dco and // is unknown for heads with nothing to sign. dcoRead := false - if !verdict.Terminal && verdict.Stage != stagelabels.None { + if !verdict.Terminal && verdict.Stage != stagelabels.None && verdict.Reason != prstage.ReasonSupersessionPending { // The commit check is the one author-side gate decided here, // before CI or a verdict: an unsigned head goes straight back // to its author (hivecommons/hive#12542). diff --git a/src/pkg/github/automerge/automerge_stage_supersession_test.go b/src/pkg/github/automerge/automerge_stage_supersession_test.go new file mode 100644 index 0000000000..7c6e1116fb --- /dev/null +++ b/src/pkg/github/automerge/automerge_stage_supersession_test.go @@ -0,0 +1,37 @@ +package automerge + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/hivecommons/hive/pkg/stageengine/prstage" + "github.com/hivecommons/hive/pkg/stagelabels" +) + +func TestApplyStageLabelsKeepsSupersessionParkOverConflictAndUnsignedHead(t *testing.T) { + api, closeAPI := seededStageAPI(t) + defer closeAPI() + clock := &contributorLaneClock{at: time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC)} + e, _, store := newStageEngine(t, apiURLOf(t, api), clock) + api.commits = map[int][]string{14795: {"unsigned commit"}} + pr := nudgePR(14795, "head", "needs-human", prstage.SupersessionPendingLabel, "needs-rebase") + e.recordStage(stageLaneTrusted, "acme/widget", "acme", "widget", pr, "check-failing:unit", false, nil) + e.recordStage(stageLaneContributor, "acme/widget", "acme", "widget", pr, "conflicting", false, nil) + if _, err := e.ApplyStageLabels(context.Background()); err != nil { + t.Fatal(err) + } + rec, ok := store.Get(stageKey("acme", "widget", 14795)) + if !ok || rec.Stage != stagelabels.NeedsDecision || rec.Reason != prstage.ReasonSupersessionPending { + t.Fatalf("record=%+v", rec) + } + for _, added := range api.added { + if slices.Contains(added, stagelabels.ReturnedToContributor.Label()) || slices.Contains(added, stagelabels.NeedsAuthor.Label()) { + t.Fatalf("sent to author: %v", api.added) + } + } + if api.commitReads != 0 { + t.Fatalf("superseded head read for repair: %d", api.commitReads) + } +} diff --git a/src/pkg/github/pr_supersession_autoclose.go b/src/pkg/github/pr_supersession_autoclose.go index f143bc53a9..eeec4eb23d 100644 --- a/src/pkg/github/pr_supersession_autoclose.go +++ b/src/pkg/github/pr_supersession_autoclose.go @@ -184,7 +184,7 @@ func renderSupersessionGraceNotice(grace time.Duration) string { if grace <= 0 { grace = DefaultSupersessionGracePeriod } - return fmt.Sprintf("A different merged PR closed the claimed issue and every file this PR touches was also changed by it. "+ + return fmt.Sprintf("A different merged PR closed the claimed issue and its implementation files were also changed by it (additional tests, docs and changelog fragments are allowed). "+ "Unless someone comments here, the sweep will close this PR as superseded once %s have passed since this notice. "+ "Reply on this PR to keep it open.", formatSupersessionGrace(grace)) } @@ -207,7 +207,7 @@ func renderSupersessionCloseComment(closer supersessionCloser, grace time.Durati if !closer.MergedAt.IsZero() { merged = "which merged at " + closer.MergedAt.UTC().Format("2006-01-02 15:04Z") } - fmt.Fprintf(&b, "Closing as superseded by %s, %s. Every file this PR touches was also changed there and nobody replied within the %s grace window, so nothing from this branch looks still needed.\n", + fmt.Fprintf(&b, "Closing as superseded by %s, %s. Its implementation files were also changed there (additional tests, docs and changelog fragments are allowed) and nobody replied within the %s grace window, so nothing from this branch looks still needed.\n", ref, merged, formatSupersessionGrace(grace)) fmt.Fprintln(&b) fmt.Fprintf(&b, "Reopen if that's wrong. The `%s` label marks PRs the supersession sweep closed.\n", SupersededLabel) @@ -317,7 +317,7 @@ func (c *Client) supersessionReviewThreads(ctx context.Context, owner, repo stri // are a subset of closer's. The grace window runs from the notice's last // edit (so a reworded notice restarts it); a human reply is detected from the // notice's creation, so a later re-edit can never hide an earlier reply. -func (c *Client) trySupersessionAutoCloseContributorPR(ctx context.Context, owner, repo string, pr *gh.PullRequest, event SupersessionSweepEvent, closer supersessionCloser, identity HiveIdentity, now time.Time, grace time.Duration) (SupersessionSweepEvent, string, error) { +func (c *Client) trySupersessionAutoCloseContributorPR(ctx context.Context, owner, repo string, pr *gh.PullRequest, event SupersessionSweepEvent, closer supersessionCloser, identity HiveIdentity, now time.Time, grace time.Duration, closingClaim bool) (SupersessionSweepEvent, string, error) { if grace <= 0 { grace = DefaultSupersessionGracePeriod } @@ -388,9 +388,13 @@ func (c *Client) trySupersessionAutoCloseContributorPR(ctx context.Context, owne } return event, "commented", nil default: + event.Reason = decision.Reason + notice := supersessionDecisionNotice(owner, closer, decision.Reason) + if closingClaim { + return c.escalateSupersededPR(ctx, owner+"/"+repo, owner, repo, pr, event, closer, notice) + } event.Action = SupersessionActionCommentedContributor - body := renderSupersessionComment(event, closer, supersessionDecisionNotice(owner, closer, decision.Reason)) - if _, err := c.ensureSupersessionComment(ctx, owner, repo, number, body); err != nil { + if _, err := c.ensureSupersessionComment(ctx, owner, repo, number, renderSupersessionComment(event, closer, notice)); err != nil { return event, "", err } return event, "commented", nil diff --git a/src/pkg/github/pr_supersession_autoclose_test.go b/src/pkg/github/pr_supersession_autoclose_test.go index cc0d229f6c..1d2c6fdd3b 100644 --- a/src/pkg/github/pr_supersession_autoclose_test.go +++ b/src/pkg/github/pr_supersession_autoclose_test.go @@ -300,7 +300,7 @@ func TestSupersessionAutoClose_Guards(t *testing.T) { if len(res.Commented) != 1 || res.Commented[0].Action != SupersessionActionEscalated { t.Fatalf("guard %q result = %+v, want escalation", tc.name, res) } - if got := obs.labels[12]; len(got) != 1 || got[0] != issueNeedsHumanLabel { + if got := obs.labels[12]; len(got) != 2 || got[0] != issueNeedsHumanLabel { t.Fatalf("guard %q labels = %v, want needs-human", tc.name, got) } if got := obs.edits[900]; !strings.Contains(got, "Files the merged PR did not touch: `pkg/c.go`") { diff --git a/src/pkg/github/pr_supersession_decision.go b/src/pkg/github/pr_supersession_decision.go new file mode 100644 index 0000000000..32406942f7 --- /dev/null +++ b/src/pkg/github/pr_supersession_decision.go @@ -0,0 +1,57 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + gh "github.com/google/go-github/v72/github" +) + +// supersessionCloseDecision accepts only an explicit close instruction. A bare +// reject is safe only after detecting a live supersession below; other decision +// text continues to the existing changes-requested path. +func supersessionCloseDecision(command unparkCommand) (closer int, ok bool) { + if command.Kind != unparkCommandDecision { + return 0, false + } + text := strings.ToLower(strings.TrimSpace(command.DecisionText())) + if text == "reject" { + return 0, true + } + const prefix = "close superseded by #" + if !strings.HasPrefix(text, prefix) { + return 0, false + } + n, err := strconv.Atoi(strings.TrimPrefix(text, prefix)) + return n, err == nil && n > 0 +} + +// Called only after eligibleUnparkCommand and TrustedMerger authorize the actor. +// No contributor branch is pushed or rewritten. +func (c *Client) closeSupersededPRDecision(ctx context.Context, displayRepo, owner, repo string, pr *gh.PullRequest, actor string, requestedCloser int) (string, error) { + if labels := labelNames(pr.Labels); c.isHeld(labels) || c.isExempt(labels) { + return "The PR is held or exempt from hive writes; clear that gate before closing it as superseded.", nil + } + identity := c.getHiveIdentity() + candidate, closer, detected, err := c.detectSupersession(ctx, displayRepo, pr, identity, time.Now()) + if err != nil { + return "", err + } + if !detected || !candidate.Closing { + return "No merged PR currently supersedes this PR's closing claims; the PR stays open.", nil + } + if requestedCloser != 0 && (requestedCloser != closer.Number || !strings.EqualFold(closer.Repo, owner+"/"+repo)) { + return fmt.Sprintf("The detected closer is %s#%d, which does not match the requested PR; the PR stays open.", closer.Repo, closer.Number), nil + } + note := fmt.Sprintf("Closing as superseded by %s#%d, following @%s's `/hive decision`. No branch changes are required.", closer.Repo, closer.Number, actor) + if err := c.CreateIssueComment(ctx, displayRepo, pr.GetNumber(), note); err != nil { + return "", err + } + if err := c.CloseSupersededPR(ctx, displayRepo, pr.GetNumber()); err != nil { + return "", err + } + return fmt.Sprintf("Closed as superseded by %s#%d, following @%s's decision.", closer.Repo, closer.Number, actor), nil +} diff --git a/src/pkg/github/pr_supersession_decision_test.go b/src/pkg/github/pr_supersession_decision_test.go new file mode 100644 index 0000000000..f05c951c7e --- /dev/null +++ b/src/pkg/github/pr_supersession_decision_test.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "strings" + "testing" + "time" + + gh "github.com/google/go-github/v72/github" + "github.com/hivecommons/hive/pkg/stageengine/prstage" +) + +func TestSupersessionCloseDecisionIsExplicit(t *testing.T) { + for _, text := range []string{"reject", "close superseded by #22"} { + cmd, ok := parseUnparkCommand("/hive decision " + text) + if !ok { + t.Fatal(text) + } + if _, ok := supersessionCloseDecision(cmd); !ok { + t.Fatal(text) + } + } + for _, text := range []string{"reject the old design and fix it", "close superseded by #0", "close superseded by #22\nkeep tests", "approve"} { + cmd, _ := parseUnparkCommand("/hive decision " + text) + if _, ok := supersessionCloseDecision(cmd); ok { + t.Fatalf("treated instruction as close: %q", text) + } + } +} + +func TestContributorSupersessionDecisionClosesWithoutPush(t *testing.T) { + for _, tc := range []struct { + name, state string + requested int + labels []string + closes bool + }{ + {name: "reject", state: "closed", closes: true}, + {name: "explicit closer", state: "closed", requested: 22, closes: true}, + {name: "wrong closer", state: "closed", requested: 23}, + {name: "reopened issue", state: "open"}, + {name: "held", state: "closed", labels: []string{"hold"}}, + } { + t.Run(tc.name, func(t *testing.T) { + prs := autoClosePRs(supersessionPRFixture{Body: "Fixes #3", Labels: tc.labels}) + server, obs := supersessionSweepServer(t, autoCloseOrg, autoCloseRepo, prs, []supersessionIssueFixture{{Number: 3, State: tc.state, GraphQLCloserPR: 22}}) + defer server.Close() + c := newTestClient(t, server, autoCloseOrg, []string{autoCloseRepo}) + c.SetAppBotLogin(autoCloseBot) + pr := &gh.PullRequest{Number: gh.Ptr(12), Body: gh.Ptr("Fixes #3"), User: &gh.User{Login: gh.Ptr("contributor")}} + for _, l := range tc.labels { + pr.Labels = append(pr.Labels, &gh.Label{Name: gh.Ptr(l)}) + } + reply, err := c.closeSupersededPRDecision(context.Background(), autoCloseOrg+"/"+autoCloseRepo, autoCloseOrg, autoCloseRepo, pr, "maintainer", tc.requested) + if err != nil { + t.Fatal(err) + } + if (len(obs.closed) == 1) != tc.closes { + t.Fatalf("closed=%v reply=%s", obs.closed, reply) + } + if tc.closes && !strings.Contains(reply, "#22") { + t.Fatal(reply) + } + if len(obs.updated) != 0 || len(obs.bodies) != 0 { + t.Fatalf("contributor branch mutated: %+v", obs) + } + }) + } +} + +func TestSupersessionSupplementalFilesNeedImplementationOverlap(t *testing.T) { + closer := map[string]bool{"pkg/fix.go": true} + for _, tc := range []struct { + name string + open map[string]bool + want int + }{ + {"ancillary only extras", map[string]bool{"pkg/fix.go": true, "pkg/fix_test.go": true, "src/docs/fix.md": true, "changelog.d/fixed-fix.md": true}, 0}, + {"unrelated implementation", map[string]bool{"pkg/other.go": true, "pkg/other_test.go": true}, 2}, + {"extra implementation", map[string]bool{"pkg/fix.go": true, "pkg/other.go": true, "pkg/fix_test.go": true}, 1}, + {"docs only", map[string]bool{"docs/fix.md": true}, 1}, + } { + if got := supersessionBlockingExtraFiles(tc.open, closer); len(got) != tc.want { + t.Fatalf("%s: %v", tc.name, got) + } + } +} + +func TestUntrustedSupersessionRejectCannotCloseContributorPR(t *testing.T) { + issue := parkedPR(12, "contributor", []string{PRNeedsDecisionStageLabel}, humanComment(4242, "reader", "/hive decision reject")) + c, rec := newUnparkPRClient(t, []unparkWireIssue{issue}, map[string]string{"reader": "read"}) + result, err := c.SweepIssueUnparkCommands(context.Background(), IssueUnparkSweepOptions{TrustedMerger: trustedMergerSet("reader")}) + if err != nil { + t.Fatal(err) + } + if result.Replies != 0 || len(rec.posted[12]) != 0 || len(rec.patched[12]) != 0 || len(rec.added[12]) != 0 { + t.Fatalf("unauthorized command acted: %+v %+v", result, rec) + } +} + +func TestHeldSupersessionRemainsObservableWithoutWrites(t *testing.T) { + pr := supersessionPRFixture{Body: "Fixes #3", Labels: []string{"sentinel-alert"}} + server, obs := supersessionSweepServer(t, autoCloseOrg, autoCloseRepo, autoClosePRs(pr), []supersessionIssueFixture{{Number: 3, State: "closed", GraphQLCloserPR: 22}}) + defer server.Close() + c := newTestClient(t, server, autoCloseOrg, []string{autoCloseRepo}) + c.SetAppBotLogin(autoCloseBot) + c.SetHoldLabels([]string{"sentinel-alert"}) + var events []SupersessionSweepEvent + opts := autoCloseEnabled + opts.Audit = func(e SupersessionSweepEvent) { events = append(events, e) } + if _, err := c.SweepSupersededOpenPRs(context.Background(), opts); err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].CloserPR != 22 || events[0].Action != SupersessionActionDetectedContributor { + t.Fatalf("events=%+v", events) + } + if len(obs.closed) != 0 || len(obs.comments[12]) != 0 || len(obs.labels[12]) != 0 { + t.Fatalf("hold bypassed: %+v", obs) + } +} + +func TestSupplementalFilesPermitGraceCloseButNotLargePRClose(t *testing.T) { + body := graceNoticeBody(t) + old := time.Now().Add(-25 * time.Hour) + for _, size := range []string{"size/S", "size/L"} { + res, obs := runAutoCloseSweep(t, supersessionPRFixture{Body: "Fixes #3", Labels: []string{size}, Files: []string{"pkg/a.go", "pkg/a_test.go", "changelog.d/fixed-a.md"}, Comments: []supersessionCommentFixture{noticeComment(body, old)}}, autoCloseEnabled) + if size == "size/S" { + if len(obs.closed) != 1 { + t.Fatalf("ancillary files blocked closure: %+v", res) + } + } else if len(obs.closed) != 0 || len(res.Commented) != 1 || res.Commented[0].Action != SupersessionActionEscalated { + t.Fatalf("large PR must need a decision: %+v closed=%v", res, obs.closed) + } + } +} + +func TestReopenedIssueClearsSupersessionCauseWithoutClearingHumanPark(t *testing.T) { + pr := supersessionPRFixture{Body: "Fixes #3", Labels: []string{issueNeedsHumanLabel, prstage.SupersessionPendingLabel}} + server, obs := supersessionSweepServer(t, autoCloseOrg, autoCloseRepo, autoClosePRs(pr), []supersessionIssueFixture{{Number: 3, State: "open"}}) + defer server.Close() + c := newTestClient(t, server, autoCloseOrg, []string{autoCloseRepo}) + c.SetAppBotLogin(autoCloseBot) + result, err := c.SweepSupersededOpenPRs(context.Background(), autoCloseEnabled) + if err != nil { + t.Fatal(err) + } + removed := obs.removedLabels[12] + if len(removed) != 1 || removed[0] != prstage.SupersessionPendingLabel || len(result.Resolved) != 1 { + t.Fatalf("removed=%v result=%+v", removed, result) + } +} diff --git a/src/pkg/github/pr_supersession_resolve.go b/src/pkg/github/pr_supersession_resolve.go index 227e432d67..c243d24339 100644 --- a/src/pkg/github/pr_supersession_resolve.go +++ b/src/pkg/github/pr_supersession_resolve.go @@ -10,6 +10,7 @@ import ( gh "github.com/google/go-github/v72/github" "github.com/hivecommons/hive/pkg/stageengine" + "github.com/hivecommons/hive/pkg/stageengine/prstage" "github.com/hivecommons/hive/pkg/stagelabels" ) @@ -142,7 +143,7 @@ func supersessionExtraFiles(open, closer map[string]bool) []string { // beside the label until the automerge sweep next looks (#13782 wore // stage/050-review-pending + needs-human for fifteen minutes). func (c *Client) parkSupersededPR(ctx context.Context, prRepo string, number int) error { - if err := c.AddLabels(ctx, prRepo, number, []string{issueNeedsHumanLabel}); err != nil { + if err := c.AddLabels(ctx, prRepo, number, []string{issueNeedsHumanLabel, prstage.SupersessionPendingLabel}); err != nil { return fmt.Errorf("labeling superseded PR %s#%d: %w", prRepo, number, err) } sink := c.StageFacts() @@ -170,6 +171,11 @@ func (c *Client) supersessionNeedsHumanByPerson(ctx context.Context, owner, repo // hive has taken the resolution over. The stage engine re-derives the lane // stage from the next automerge pass (leaving 085 is never a regression). func (c *Client) releaseSupersessionPark(ctx context.Context, prRepo string, pr *gh.PullRequest) error { + if supersessionHasLabel(pr, prstage.SupersessionPendingLabel) { + if err := c.RemoveLabel(ctx, prRepo, pr.GetNumber(), prstage.SupersessionPendingLabel); err != nil { + return err + } + } if !supersessionHasLabel(pr, issueNeedsHumanLabel) { return nil } @@ -199,25 +205,28 @@ const supersessionEscalationMark = "**Decision needed" // one the hive cannot attribute, or one next to no sweep notice is left // alone. The lane re-derives the stage from the labels on its next pass. func (c *Client) releaseStaleSupersessionPark(ctx context.Context, prRepo, owner, repo string, pr *gh.PullRequest) (SupersessionSweepEvent, string, error) { - if !supersessionHasLabel(pr, issueNeedsHumanLabel) { - return SupersessionSweepEvent{}, "", nil - } - notice, err := c.findSupersessionComment(ctx, owner, repo, pr.GetNumber()) - if err != nil { - return SupersessionSweepEvent{}, "", err + changed := false + if supersessionHasLabel(pr, prstage.SupersessionPendingLabel) { + if err := c.RemoveLabel(ctx, prRepo, pr.GetNumber(), prstage.SupersessionPendingLabel); err != nil { + return SupersessionSweepEvent{}, "", err + } + changed = true } - if notice == nil || !strings.Contains(notice.GetBody(), supersessionEscalationMark) { - return SupersessionSweepEvent{}, "", nil + if supersessionHasLabel(pr, issueNeedsHumanLabel) { + notice, err := c.findSupersessionComment(ctx, owner, repo, pr.GetNumber()) + if err != nil { + return SupersessionSweepEvent{}, "", err + } + if notice != nil && strings.Contains(notice.GetBody(), supersessionEscalationMark) && !c.supersessionNeedsHumanByPerson(ctx, owner, repo, pr) { + if err := c.RemoveLabel(ctx, prRepo, pr.GetNumber(), issueNeedsHumanLabel); err != nil { + return SupersessionSweepEvent{}, "", err + } + changed = true + } } - if c.supersessionNeedsHumanByPerson(ctx, owner, repo, pr) { + if !changed { return SupersessionSweepEvent{}, "", nil } - if err := c.releaseSupersessionPark(ctx, prRepo, pr); err != nil { - return SupersessionSweepEvent{}, "", err - } - if c.logger != nil { - c.logger.Info("supersession sweep: released its needs-human, the cause is gone", "repo", prRepo, "pr", pr.GetNumber()) - } event := SupersessionSweepEvent{Repo: prRepo, Number: pr.GetNumber(), Author: safeGetLogin(pr.GetUser()), Action: SupersessionActionReleased} return event, "resolved", nil } @@ -375,6 +384,12 @@ func (c *Client) escalateSupersededPR(ctx context.Context, prRepo, owner, repo s return event, "", err } if !changed { + if supersessionHasLabel(pr, issueNeedsHumanLabel) && !supersessionHasLabel(pr, prstage.SupersessionPendingLabel) { + if err := c.parkSupersededPR(ctx, prRepo, pr.GetNumber()); err != nil { + return event, "", err + } + return event, "commented", nil + } return event, "", nil } if err := c.parkSupersededPR(ctx, prRepo, pr.GetNumber()); err != nil { @@ -391,3 +406,29 @@ func supersessionSortedFiles(files map[string]bool) []string { sort.Strings(out) return out } + +// Ignore ancillary additions only when at least one implementation file also +// overlaps. A docs-only PR or unrelated implementation is never a duplicate +// merely because it mentions the same closed issue. +func supersessionBlockingExtraFiles(open, closer map[string]bool) []string { + ancillary := func(f string) bool { + return strings.HasSuffix(f, "_test.go") || strings.HasSuffix(f, ".md") || strings.HasPrefix(f, "docs/") || strings.Contains(f, "/docs/") || strings.HasPrefix(f, "changelog.d/") + } + overlap := false + for f := range open { + if closer[f] && !ancillary(f) { + overlap = true + } + } + extra := supersessionExtraFiles(open, closer) + if !overlap { + return extra + } + var blocking []string + for _, f := range extra { + if !ancillary(f) { + blocking = append(blocking, f) + } + } + return blocking +} diff --git a/src/pkg/github/pr_supersession_resolve_test.go b/src/pkg/github/pr_supersession_resolve_test.go index cb4b956944..9b19347e1a 100644 --- a/src/pkg/github/pr_supersession_resolve_test.go +++ b/src/pkg/github/pr_supersession_resolve_test.go @@ -232,7 +232,7 @@ func TestSupersessionResolve_BelowL6EscalatesOwnPRThroughStageEngine(t *testing. if len(parked) != 1 || parked[0] != 13782 { t.Fatalf("parked = %v, want #13782 at 085", parked) } - if got := obs.labels[13782]; len(got) != 1 || got[0] != issueNeedsHumanLabel { + if got := obs.labels[13782]; len(got) != 2 || got[0] != issueNeedsHumanLabel { t.Fatalf("labels = %v", got) } if obs.updated[13782] != "" || len(obs.closed) != 0 { @@ -248,7 +248,7 @@ func TestSupersessionResolve_ContributorExtraFilesEscalatesWithFiles(t *testing. if len(parked) != 1 || parked[0] != 13782 { t.Fatalf("parked = %v, want #13782 at 085", parked) } - if got := obs.labels[13782]; len(got) != 1 || got[0] != issueNeedsHumanLabel { + if got := obs.labels[13782]; len(got) != 2 || got[0] != issueNeedsHumanLabel { t.Fatalf("labels = %v", got) } comment := obs.comments[13782][0] diff --git a/src/pkg/github/pr_supersession_sweep.go b/src/pkg/github/pr_supersession_sweep.go index a33fc286b4..09a3dbbdf2 100644 --- a/src/pkg/github/pr_supersession_sweep.go +++ b/src/pkg/github/pr_supersession_sweep.go @@ -48,6 +48,7 @@ type SupersessionSweepEvent struct { Issue int CloserPR int Action string + Reason string } type SupersessionSweepResult struct { @@ -153,17 +154,18 @@ func (c *Client) listOpenPRsForSupersessionSweep(ctx context.Context, owner, rep } func (c *Client) trySweepSupersededPR(ctx context.Context, prRepo, prOwner, prRepoName string, pr *gh.PullRequest, identity HiveIdentity, now time.Time, opts SupersessionSweepOptions) (SupersessionSweepEvent, string, error) { - // A hold or do-not-merge label is an operator's explicit instruction to - // leave the PR alone. Honor it the same way the automerge sweep does: - // no comment, no label, no close — before any GitHub call is made. - if labels := labelNames(pr.Labels); c.isHeld(labels) || c.isExempt(labels) { - return SupersessionSweepEvent{}, "", nil - } + // Holds gate writes, not detection: a sentinel must not hide supersession + // from the tracker. No comments, labels or closes happen while held. + labels := labelNames(pr.Labels) + held := c.isHeld(labels) || c.isExempt(labels) candidate, closer, detected, err := c.detectSupersession(ctx, prRepo, pr, identity, now) if err != nil { return SupersessionSweepEvent{}, "", err } if !detected { + if held { + return SupersessionSweepEvent{}, "", nil + } // Nothing supersedes this PR (any more). A park an earlier pass // put on it for a cause that has since gone — the claimed issue was // reopened, the claim was rewritten — clears with that cause @@ -180,6 +182,14 @@ func (c *Client) trySweepSupersededPR(ctx context.Context, prRepo, prOwner, prRe CloserPR: closer.Number, } + if held { + if opts.Audit != nil && !identity.Matches(event.Author) { + event.Action = SupersessionActionDetectedContributor + event.Reason = "held; awaiting an operator decision" + opts.Audit(event) + } + return event, "", nil + } reason := "" openFiles, closerFiles, err := c.prFileSets(ctx, prOwner, prRepoName, pr.GetNumber(), closer) if err != nil { @@ -200,6 +210,14 @@ func (c *Client) trySweepSupersededPR(ctx context.Context, prRepo, prOwner, prRe return c.resolveOwnSupersededPR(ctx, prRepo, prOwner, prRepoName, pr, event, closer, closerFiles, subset, level) } + blockingExtra := supersessionBlockingExtraFiles(openFiles, closerFiles) + if len(blockingExtra) == 0 { + reason = "" + } + event.Reason = reason + if reason == "" { + _, event.Reason = supersessionSizeAllowsAutoClose(supersessionSizeLabel(pr), -1) + } // Record every detection, even when the notice is already present or // automatic closure is disabled. This does not consume a write action. if opts.Audit != nil { @@ -212,12 +230,16 @@ func (c *Client) trySweepSupersededPR(ctx context.Context, prRepo, prOwner, prRe event.Action = SupersessionActionKeptOpen opts.Audit(event) } + if supersessionHasLabel(pr, issueNeedsHumanLabel) { + return c.releaseStaleSupersessionPark(ctx, prRepo, prOwner, prRepoName, pr) + } + reason = supersessionReasonKeptOpen } if reason == "" && opts.CloseContributorPRs && level >= acmmLevelAutonomous { - return c.trySupersessionAutoCloseContributorPR(ctx, prOwner, prRepoName, pr, event, closer, identity, now, opts.GracePeriod) + return c.trySupersessionAutoCloseContributorPR(ctx, prOwner, prRepoName, pr, event, closer, identity, now, opts.GracePeriod, candidate.Closing) } notice := supersessionDecisionNotice(prOwner, closer, reason) - if !subset { + if reason == supersessionReasonExtraFiles { notice += "\n\nFiles the merged PR did not touch: " + supersessionFilesSummary(extra) + "." if candidate.Closing { // A contributor's PR that adds files the merged PR did not: the @@ -231,6 +253,9 @@ func (c *Client) trySweepSupersededPR(ctx context.Context, prRepo, prOwner, prRe // and #14090 (Part of / Refs a closed tracker) sat at 085 for // this until a maintainer removed the label (hivecommons/hive#14155). } + if candidate.Closing && reason != supersessionReasonKeptOpen { + return c.escalateSupersededPR(ctx, prRepo, prOwner, prRepoName, pr, event, closer, notice) + } event.Action = SupersessionActionCommentedContributor body := renderSupersessionComment(event, closer, notice) changed, err := c.ensureSupersessionComment(ctx, prOwner, prRepoName, pr.GetNumber(), body) @@ -448,7 +473,7 @@ func supersessionDecisionNotice(operator string, closer supersessionCloser, note return fmt.Sprintf("**Decision needed (%s):** #%d already merged and closed the claimed issue, but %s. Recommended: **keep open** until the extra changes are reviewed, or close as superseded if they are not wanted. Another option: merge anyway.", addressee, closer.Number, note) } var b strings.Builder - fmt.Fprintf(&b, "**Decision needed (%s):** this PR appears to duplicate #%d, which already merged: every file it touches was also changed there (the sweep compares file names, not contents). Recommended: **close as superseded**. Other options: keep open, or merge anyway.", addressee, closer.Number) + fmt.Fprintf(&b, "**Decision needed (%s):** this PR appears to duplicate #%d, which already merged: its implementation files overlap the merged PR (additional tests, docs and changelog fragments do not block this recommendation; the sweep compares file names, not contents). Recommended: **close as superseded**. Other options: keep open, or merge anyway.", addressee, closer.Number) if note != "" { fmt.Fprintf(&b, " The sweep did not close it automatically: %s.", note) } diff --git a/src/pkg/github/pr_supersession_sweep_test.go b/src/pkg/github/pr_supersession_sweep_test.go index da12e25c10..835ee826b6 100644 --- a/src/pkg/github/pr_supersession_sweep_test.go +++ b/src/pkg/github/pr_supersession_sweep_test.go @@ -473,7 +473,7 @@ func TestSweepSupersededOpenPRs_BelowL6LabelsOwnPR(t *testing.T) { if len(res.Commented) != 1 || len(obs.closed) != 0 { t.Fatalf("below L6 should comment without closing: result=%+v closed=%v", res, obs.closed) } - if got := obs.labels[13]; len(got) != 1 || got[0] != issueNeedsHumanLabel { + if got := obs.labels[13]; len(got) != 2 || got[0] != issueNeedsHumanLabel { t.Fatalf("labels on #13 = %v, want needs-human", got) } if got := parkedPRs(rec); len(got) != 1 || got[0] != 13 { @@ -649,7 +649,7 @@ func TestSweepSupersededOpenPRs_AllClosingClaimsClosedEscalates(t *testing.T) { if len(res.Commented) != 1 || res.Commented[0].Issue != 30 || res.Commented[0].CloserPR != 40 || res.Commented[0].Action != SupersessionActionEscalated { t.Fatalf("expected needs-human escalation on #30 via #40: %+v", res) } - if got := obs.labels[21]; len(got) != 1 || got[0] != issueNeedsHumanLabel { + if got := obs.labels[21]; len(got) != 2 || got[0] != issueNeedsHumanLabel { t.Fatalf("labels on #21 = %v, want needs-human", got) } if got := parkedPRs(rec); len(got) != 1 || got[0] != 21 { @@ -708,7 +708,7 @@ func TestSweepSupersededOpenPRs_DoesNotReapplyNeedsHumanAfterClear(t *testing.T) t.Fatalf("edited = %v, want %v (edit body: %q)", edited, tc.wantEdited, obs.edits[501]) } if tc.wantLabel { - if len(res.Commented) != 1 || len(obs.labels[22]) != 1 || obs.labels[22][0] != issueNeedsHumanLabel { + if len(res.Commented) != 1 || len(obs.labels[22]) != 2 || obs.labels[22][0] != issueNeedsHumanLabel { t.Fatalf("new escalation must label: result=%+v labels=%v", res, obs.labels[22]) } return @@ -876,6 +876,7 @@ func TestSweepSupersededOpenPRs_ReleasesOwnParkWhenCauseIsGone(t *testing.T) { name: "closing claim still closed: park stands", body: "Fixes #3", issueState: "closed", labels: []string{issueNeedsHumanLabel}, labelEvents: hiveApplied, comments: notice(escalation()), + wantPark: true, }, { name: "closing claim still closed, not yet parked: escalates", diff --git a/src/pkg/github/pr_unpark_decision.go b/src/pkg/github/pr_unpark_decision.go index 81b55ba931..115826b095 100644 --- a/src/pkg/github/pr_unpark_decision.go +++ b/src/pkg/github/pr_unpark_decision.go @@ -67,6 +67,17 @@ func (c *Client) tryUnparkPullRequest(ctx context.Context, displayRepo, owner, r selfAuthored := c.isHiveAccount(pr.GetUser()) || labelPresent(issueLabelNames(pr.Labels), authorkind.Hive.Label()) reply := "" + if closer, closeRequested := supersessionCloseDecision(command); closeRequested { + reply, err := c.closeSupersededPRDecision(ctx, displayRepo, owner, repo, pr, actor, closer) + if err != nil { + return false, err + } + if err := c.postUnparkReply(ctx, owner, repo, number, candidate.GetID(), reply); err != nil { + return false, err + } + result.Replies++ + return true, nil + } switch { case command.Kind == unparkCommandDecision && selfAuthored && head != "": mode := prDecisionModeFor(command) @@ -414,6 +425,7 @@ func renderPRHelpReply(selfAuthored bool, head string) string { } else { b.WriteString("- Leave a **changes requested** review and the author is nudged with your text; the hive does not push to a PR it did not open.\n") } + b.WriteString("- For a superseded PR, reply `/hive decision reject` or `/hive decision close superseded by #N` to close it.\n") fmt.Fprintf(&b, "\nSee <%s>.", maintainerCommandsDocURL) return b.String() } diff --git a/src/pkg/github/review_queue.go b/src/pkg/github/review_queue.go index b3f99b4389..a71808531d 100644 --- a/src/pkg/github/review_queue.go +++ b/src/pkg/github/review_queue.go @@ -269,6 +269,7 @@ const ReviewQueueSupersededReason = "superseded" // ReviewQueueSuperseded describes a PR whose claimed issue was closed by a // different merged PR. Grace timestamps are present only for automatic closure. type ReviewQueueSuperseded struct { + KeepReason string `json:"keep_reason,omitempty"` Reason string `json:"reason"` Issue string `json:"issue,omitempty"` CloserRepo string `json:"closer_repo,omitempty"` diff --git a/src/pkg/stageengine/derive_pr.go b/src/pkg/stageengine/derive_pr.go index 72af92e547..26e3ca9054 100644 --- a/src/pkg/stageengine/derive_pr.go +++ b/src/pkg/stageengine/derive_pr.go @@ -246,7 +246,7 @@ func passDecision(verdict stageVerdict, role stagelabels.Role, obs stageObservat } } var human *stagelabels.Route - if verdict.Human && !verdict.Terminal { + if verdict.Human && !verdict.Terminal && verdict.Reason != prstage.ReasonSupersessionPending { if pass.Route != nil { route := stagelabels.RouteHuman(*pass.Route) verdict.Stage = route.Stage @@ -259,7 +259,7 @@ func passDecision(verdict stageVerdict, role stagelabels.Role, obs stageObservat } } dco := f.PR.DCO - if !verdict.Terminal && verdict.Stage != stagelabels.None && pass.DCOKnown { + if !verdict.Terminal && verdict.Stage != stagelabels.None && pass.DCOKnown && verdict.Reason != prstage.ReasonSupersessionPending { dco = pass.DCO if pass.DCO == stagelabels.DCONo && pass.DCORequired { verdict.Stage = FixingStage(obs) diff --git a/src/pkg/stageengine/derive_pr_classify.go b/src/pkg/stageengine/derive_pr_classify.go index bd7d1eaf47..4aa049e61b 100644 --- a/src/pkg/stageengine/derive_pr_classify.go +++ b/src/pkg/stageengine/derive_pr_classify.go @@ -326,6 +326,9 @@ func ClassifyStage(o stageObservation) stageVerdict { if o.Merged && reason == "" { return stageVerdict{Terminal: true, Rank: stageRankOwned} } + if reason != "closed" && reason != "gone" && reason != "label-removed" && HasLabelFold(o.Labels, prstage.SupersessionPendingLabel) && HasLabelFold(o.Labels, NeedsHumanLabel) && !HasLabelFold(o.Labels, "hive/keep-open") { + return stageVerdict{Stage: stagelabels.NeedsDecision, Rank: stageRankOwned, Human: true, Reason: prstage.ReasonSupersessionPending} + } if o.Err { // The lane tripped before deciding. If every author-side gate is // already satisfied the PR is waiting on the hive, not the author. diff --git a/src/pkg/stageengine/derive_pr_supersession_test.go b/src/pkg/stageengine/derive_pr_supersession_test.go new file mode 100644 index 0000000000..6dd692662a --- /dev/null +++ b/src/pkg/stageengine/derive_pr_supersession_test.go @@ -0,0 +1,38 @@ +package stageengine + +import ( + "testing" + + "github.com/hivecommons/hive/pkg/authorkind" + "github.com/hivecommons/hive/pkg/stageengine/prstage" + "github.com/hivecommons/hive/pkg/stagelabels" +) + +func TestSupersessionParkSurvivesRepairAndPassOverrides(t *testing.T) { + for _, kind := range []authorkind.Kind{authorkind.Human, authorkind.Clanker} { + for _, reason := range []string{"needs-rebase", "check-failing:unit", "label:needs-human", "no-claim"} { + t.Run(string(kind)+"/"+reason, func(t *testing.T) { + o := stageObservation{Lane: stageLaneContributor, Reason: reason, AuthorKind: kind, Labels: []string{NeedsHumanLabel, prstage.SupersessionPendingLabel, "needs-rebase"}} + f := FactsFromPRObservations([]stageObservation{o}) + f.PR.Pass = &PRPassFacts{AuthorKind: kind, DCOKnown: true, DCO: stagelabels.DCONo, DCORequired: true, + Route: &stagelabels.Evidence{}} + d := DerivePR(f) + if d.Stage != stagelabels.NeedsDecision || d.Reason != prstage.ReasonSupersessionPending { + t.Fatalf("superseded PR sent to repair: %+v", d) + } + // Once the cause or the park is cleared the ordinary lane owns it. + for _, labels := range [][]string{{NeedsHumanLabel}, {prstage.SupersessionPendingLabel}, {NeedsHumanLabel, prstage.SupersessionPendingLabel, "hive/keep-open"}} { + o.Labels = labels + if v := ClassifyStage(o); v.Reason == prstage.ReasonSupersessionPending { + t.Fatalf("stale park retained: %+v", v) + } + } + o.Labels = []string{NeedsHumanLabel, prstage.SupersessionPendingLabel} + o.Reason = "closed" + if v := ClassifyStage(o); !v.Terminal { + t.Fatalf("closed PR still parked: %+v", v) + } + }) + } + } +} diff --git a/src/pkg/stageengine/prstage/reasons.go b/src/pkg/stageengine/prstage/reasons.go index 8e1c49a6ae..74b8a37a78 100644 --- a/src/pkg/stageengine/prstage/reasons.go +++ b/src/pkg/stageengine/prstage/reasons.go @@ -6,6 +6,11 @@ import ( "github.com/hivecommons/hive/pkg/stagelabels" ) +// SupersessionPendingLabel records the sweep's unresolved supersession park. +// It is independent of CI/head state and is removed when the cause clears. +const SupersessionPendingLabel = "hive/supersession-pending" +const ReasonSupersessionPending = "supersession-decision-pending" + // Lane reasons are shared by the producers and the stage classifier. const ( ReviewGateOutcomeAwaiting = "awaiting-review-approval" From 30a71c9f7d4e7d3ef2932f41def2cdce2ef6d398 Mon Sep 17 00:00:00 2001 From: thatguywob Date: Wed, 28 Oct 2026 04:00:17 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20align=20supersession?= =?UTF-8?q?=20API=20fixtures=20with=20decision=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist supersession park labels in the dashboard sweep fixture and assert the second pass makes no additional writes. Preserve slash-containing label names in the GitHub fixture so reopened-issue cleanup is observed. Signed-off-by: thatguywob --- .../api_review_queue_superseded_test.go | 18 +++++++++++++++++- src/pkg/github/pr_supersession_sweep_test.go | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pkg/dashboard/api_review_queue_superseded_test.go b/src/pkg/dashboard/api_review_queue_superseded_test.go index 39bafef0c8..22dce4c3e6 100644 --- a/src/pkg/dashboard/api_review_queue_superseded_test.go +++ b/src/pkg/dashboard/api_review_queue_superseded_test.go @@ -15,6 +15,7 @@ import ( "github.com/hivecommons/hive/pkg/config" ghpkg "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/stageengine/prstage" ) // useRingSupersessionAudit points the queue's on-disk audit scan at an empty @@ -370,11 +371,17 @@ func TestReviewQueue_SweepDetectionWithoutAutoClose(t *testing.T) { useRingSupersessionAudit(t) s := reviewQueueTestServer(t, filepath.Join(t.TempDir(), "missing.json")) var notice string + var labels []string + var labelWrites int var writes int api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.URL.Path { case "GET /repos/myorg/repo1/pulls": - fmt.Fprint(w, `[{"number":2,"body":"Fixes #3","user":{"login":"alice"}}]`) + wireLabels := make([]map[string]string, 0, len(labels)) + for _, label := range labels { + wireLabels = append(wireLabels, map[string]string{"name": label}) + } + json.NewEncoder(w).Encode([]map[string]any{{"number": 2, "body": "Fixes #3", "user": map[string]string{"login": "alice"}, "labels": wireLabels}}) case "GET /repos/myorg/repo1/issues/3": fmt.Fprint(w, `{"number":3,"state":"closed"}`) case "POST /graphql": @@ -387,6 +394,12 @@ func TestReviewQueue_SweepDetectionWithoutAutoClose(t *testing.T) { } else { json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "body": notice}}) } + case "POST /repos/myorg/repo1/issues/2/labels": + if err := json.NewDecoder(r.Body).Decode(&labels); err != nil { + t.Error(err) + } + labelWrites++ + fmt.Fprint(w, `[]`) case "POST /repos/myorg/repo1/issues/2/comments": var in struct { Body string `json:"body"` @@ -442,4 +455,7 @@ func TestReviewQueue_SweepDetectionWithoutAutoClose(t *testing.T) { if writes != 1 { t.Fatalf("notice writes = %d, want 1", writes) } + if labelWrites != 1 || strings.Join(labels, ",") != "needs-human,"+prstage.SupersessionPendingLabel { + t.Fatalf("park labels = %v, writes = %d; want one supersession park", labels, labelWrites) + } } diff --git a/src/pkg/github/pr_supersession_sweep_test.go b/src/pkg/github/pr_supersession_sweep_test.go index 835ee826b6..8fed077c94 100644 --- a/src/pkg/github/pr_supersession_sweep_test.go +++ b/src/pkg/github/pr_supersession_sweep_test.go @@ -280,7 +280,8 @@ func supersessionSweepServer(t *testing.T, org, repo string, prs []supersessionP _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "body": payload.Body}) return } - parts := strings.Split(rest, "/") + // A label name may contain slashes; keep the label suffix intact. + parts := strings.SplitN(rest, "/", 3) n, _ := strconv.Atoi(parts[0]) switch { case len(parts) == 1 && r.Method == "GET": From beb4233d8f4beddf313aa72671d4957368e6a58a Mon Sep 17 00:00:00 2001 From: ci-maintainer <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:11:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20record=20the=20supers?= =?UTF-8?q?ession=20park=20in=20the=20sweep=20fixture=20and=20keep=20bare?= =?UTF-8?q?=20reject=20routable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestReviewQueue_SweepDetectionWithoutAutoClose built its client without a stage fact sink, so the new escalation path re-read the PR through the live reconciler and hit the fixture's 404 (dashboard shards 4/4 and shuffle 3/3). Install the recording sink the pkg/github sweep tests use and assert the Parked fact. A bare `/hive decision reject` on a PR nothing supersedes now continues to the changes-requested path instead of being answered with "stays open"; only the explicit `close superseded by #N` form is always answered by the supersession handler. Covered by a sweep-level regression test and the maintainer-commands doc. Hive-Fix-Attempt: 1/6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: ci-maintainer <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- src/docs/maintainer-commands.md | 5 +- .../api_review_queue_superseded_test.go | 14 ++++++ src/pkg/github/pr_supersession_decision.go | 30 ++++++++---- .../github/pr_supersession_decision_test.go | 48 +++++++++++++++++-- src/pkg/github/pr_unpark_decision.go | 12 +++-- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/src/docs/maintainer-commands.md b/src/docs/maintainer-commands.md index 2945054b4c..3e0769d74d 100644 --- a/src/docs/maintainer-commands.md +++ b/src/docs/maintainer-commands.md @@ -65,7 +65,10 @@ maintainer with repository write access can reply `/hive decision reject` or are closed and a different merged PR closed them; an explicit `#N` must match that closer. It comments and closes the PR with `hive/superseded`, including PRs opened by contributors, without pushing to their branches. Hold and -`do-not-merge` gates still prevent the close. The sweep's +`do-not-merge` gates still prevent the close. When nothing supersedes the PR, +a bare `reject` keeps its ordinary meaning (changes requested, nudging the +author), while an explicit `close superseded by #N` is answered with why the +PR stays open. The sweep's `hive/supersession-pending` cause label keeps the unresolved decision at 085 through CI or rebase failures; reopening the issue, rewriting the claim, or choosing `hive/keep-open` clears the sweep's park on its next pass. diff --git a/src/pkg/dashboard/api_review_queue_superseded_test.go b/src/pkg/dashboard/api_review_queue_superseded_test.go index 22dce4c3e6..a347d79eb3 100644 --- a/src/pkg/dashboard/api_review_queue_superseded_test.go +++ b/src/pkg/dashboard/api_review_queue_superseded_test.go @@ -16,6 +16,7 @@ import ( "github.com/hivecommons/hive/pkg/config" ghpkg "github.com/hivecommons/hive/pkg/github" "github.com/hivecommons/hive/pkg/stageengine/prstage" + "github.com/hivecommons/hive/pkg/stageengine/stageenginetest" ) // useRingSupersessionAudit points the queue's on-disk audit scan at an empty @@ -418,6 +419,10 @@ func TestReviewQueue_SweepDetectionWithoutAutoClose(t *testing.T) { defer api.Close() client := ghpkg.NewClient("token", "myorg", []string{"repo1"}, slog.Default(), api.URL) client.SetAppBotLogin("hive-app[bot]") + // The park goes through the stage engine; record it instead of letting the + // default sink re-read the PR and reconcile against the shared label store. + facts := &stageenginetest.Recorder{} + client.SetStageFactSink(facts) for pass := 0; pass < 2; pass++ { res, err := client.SweepSupersededOpenPRs(context.Background(), ghpkg.SupersessionSweepOptions{ ACMMLevelForRepo: func(string) int { return 6 }, @@ -458,4 +463,13 @@ func TestReviewQueue_SweepDetectionWithoutAutoClose(t *testing.T) { if labelWrites != 1 || strings.Join(labels, ",") != "needs-human,"+prstage.SupersessionPendingLabel { t.Fatalf("park labels = %v, writes = %d; want one supersession park", labels, labelWrites) } + var parked int + for _, f := range facts.All() { + if f.Method == "Parked" && f.Ref.Number == 2 && f.Label == "needs-human" { + parked++ + } + } + if parked != 1 { + t.Fatalf("Parked facts for PR 2 = %d, want 1: %+v", parked, facts.All()) + } } diff --git a/src/pkg/github/pr_supersession_decision.go b/src/pkg/github/pr_supersession_decision.go index 32406942f7..fa0a63f610 100644 --- a/src/pkg/github/pr_supersession_decision.go +++ b/src/pkg/github/pr_supersession_decision.go @@ -31,27 +31,39 @@ func supersessionCloseDecision(command unparkCommand) (closer int, ok bool) { // Called only after eligibleUnparkCommand and TrustedMerger authorize the actor. // No contributor branch is pushed or rewritten. -func (c *Client) closeSupersededPRDecision(ctx context.Context, displayRepo, owner, repo string, pr *gh.PullRequest, actor string, requestedCloser int) (string, error) { +// +// handled is false when a bare `reject` finds no live supersession (or the PR +// is held): the decision is not about supersession, so the caller continues +// to the existing changes-requested path. An explicit `close superseded by +// #N` is always answered here, since it can mean nothing else. +func (c *Client) closeSupersededPRDecision(ctx context.Context, displayRepo, owner, repo string, pr *gh.PullRequest, actor string, requestedCloser int) (reply string, handled bool, err error) { + explicit := requestedCloser != 0 if labels := labelNames(pr.Labels); c.isHeld(labels) || c.isExempt(labels) { - return "The PR is held or exempt from hive writes; clear that gate before closing it as superseded.", nil + if !explicit { + return "", false, nil + } + return "The PR is held or exempt from hive writes; clear that gate before closing it as superseded.", true, nil } identity := c.getHiveIdentity() candidate, closer, detected, err := c.detectSupersession(ctx, displayRepo, pr, identity, time.Now()) if err != nil { - return "", err + return "", false, err } if !detected || !candidate.Closing { - return "No merged PR currently supersedes this PR's closing claims; the PR stays open.", nil + if !explicit { + return "", false, nil + } + return "No merged PR currently supersedes this PR's closing claims; the PR stays open.", true, nil } - if requestedCloser != 0 && (requestedCloser != closer.Number || !strings.EqualFold(closer.Repo, owner+"/"+repo)) { - return fmt.Sprintf("The detected closer is %s#%d, which does not match the requested PR; the PR stays open.", closer.Repo, closer.Number), nil + if explicit && (requestedCloser != closer.Number || !strings.EqualFold(closer.Repo, owner+"/"+repo)) { + return fmt.Sprintf("The detected closer is %s#%d, which does not match the requested PR; the PR stays open.", closer.Repo, closer.Number), true, nil } note := fmt.Sprintf("Closing as superseded by %s#%d, following @%s's `/hive decision`. No branch changes are required.", closer.Repo, closer.Number, actor) if err := c.CreateIssueComment(ctx, displayRepo, pr.GetNumber(), note); err != nil { - return "", err + return "", false, err } if err := c.CloseSupersededPR(ctx, displayRepo, pr.GetNumber()); err != nil { - return "", err + return "", false, err } - return fmt.Sprintf("Closed as superseded by %s#%d, following @%s's decision.", closer.Repo, closer.Number, actor), nil + return fmt.Sprintf("Closed as superseded by %s#%d, following @%s's decision.", closer.Repo, closer.Number, actor), true, nil } diff --git a/src/pkg/github/pr_supersession_decision_test.go b/src/pkg/github/pr_supersession_decision_test.go index f05c951c7e..26ba399325 100644 --- a/src/pkg/github/pr_supersession_decision_test.go +++ b/src/pkg/github/pr_supersession_decision_test.go @@ -7,6 +7,7 @@ import ( "time" gh "github.com/google/go-github/v72/github" + "github.com/hivecommons/hive/pkg/review" "github.com/hivecommons/hive/pkg/stageengine/prstage" ) @@ -34,12 +35,18 @@ func TestContributorSupersessionDecisionClosesWithoutPush(t *testing.T) { requested int labels []string closes bool + // handled is false only for a bare reject that found no live + // supersession: the decision then continues to the + // changes-requested path instead of being answered here. + handled bool }{ - {name: "reject", state: "closed", closes: true}, - {name: "explicit closer", state: "closed", requested: 22, closes: true}, - {name: "wrong closer", state: "closed", requested: 23}, + {name: "reject", state: "closed", closes: true, handled: true}, + {name: "explicit closer", state: "closed", requested: 22, closes: true, handled: true}, + {name: "wrong closer", state: "closed", requested: 23, handled: true}, {name: "reopened issue", state: "open"}, + {name: "reopened issue explicit", state: "open", requested: 22, handled: true}, {name: "held", state: "closed", labels: []string{"hold"}}, + {name: "held explicit", state: "closed", requested: 22, labels: []string{"hold"}, handled: true}, } { t.Run(tc.name, func(t *testing.T) { prs := autoClosePRs(supersessionPRFixture{Body: "Fixes #3", Labels: tc.labels}) @@ -51,10 +58,13 @@ func TestContributorSupersessionDecisionClosesWithoutPush(t *testing.T) { for _, l := range tc.labels { pr.Labels = append(pr.Labels, &gh.Label{Name: gh.Ptr(l)}) } - reply, err := c.closeSupersededPRDecision(context.Background(), autoCloseOrg+"/"+autoCloseRepo, autoCloseOrg, autoCloseRepo, pr, "maintainer", tc.requested) + reply, handled, err := c.closeSupersededPRDecision(context.Background(), autoCloseOrg+"/"+autoCloseRepo, autoCloseOrg, autoCloseRepo, pr, "maintainer", tc.requested) if err != nil { t.Fatal(err) } + if handled != tc.handled || (!handled && reply != "") { + t.Fatalf("handled=%t reply=%q, want handled=%t", handled, reply, tc.handled) + } if (len(obs.closed) == 1) != tc.closes { t.Fatalf("closed=%v reply=%s", obs.closed, reply) } @@ -149,3 +159,33 @@ func TestReopenedIssueClearsSupersessionCauseWithoutClearingHumanPark(t *testing t.Fatalf("removed=%v result=%+v", removed, result) } } + +// A bare `reject` on a hive PR nothing supersedes is an instruction, not a +// close: it still reaches the changes-requested path (the author is kicked) +// instead of being answered with "the PR stays open". +func TestBareRejectWithoutSupersessionStillRequestsChanges(t *testing.T) { + claimed := unparkWireIssue{Number: 1, Title: "tunnel drain", State: "open", User: wireUser{Login: "reporter"}} + issues := []unparkWireIssue{claimed, hiveParkedPR(humanComment(4242, "clubanderson", "/hive decision reject"))} + c, rec := newUnparkPRClient(t, issues, map[string]string{"clubanderson": "admin"}) + store := review.NewHumanChangeRequests("") + result, err := c.SweepIssueUnparkCommands(context.Background(), IssueUnparkSweepOptions{HumanChangeRequests: store, TrustedMerger: trustedMergerSet("clubanderson")}) + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(result.PRDecisions) != 1 || result.Replies != 0 || result.Skipped != 0 { + t.Fatalf("result = %+v, want one PR decision routed as changes requested", result) + } + if req, ok := store.For("org/repo", liveDecisionPR, liveDecisionHead); !ok || req.Body != "reject" { + t.Fatalf("recorded request = %+v, %v", req, ok) + } + if len(rec.patched[liveDecisionPR]) != 0 { + t.Fatalf("PR closed: %v", rec.patched[liveDecisionPR]) + } + if len(rec.posted[liveDecisionPR]) != 1 || strings.Contains(rec.posted[liveDecisionPR][0], "stays open") { + t.Fatalf("reply = %v, want the changes-requested reply", rec.posted[liveDecisionPR]) + } + facts := prDecisionFacts(t, c) + if len(facts) != 1 || !facts[0].Instruction { + t.Fatalf("stage facts = %+v, want one PRDecided instruction", facts) + } +} diff --git a/src/pkg/github/pr_unpark_decision.go b/src/pkg/github/pr_unpark_decision.go index 115826b095..7cc9afdd3e 100644 --- a/src/pkg/github/pr_unpark_decision.go +++ b/src/pkg/github/pr_unpark_decision.go @@ -68,15 +68,17 @@ func (c *Client) tryUnparkPullRequest(ctx context.Context, displayRepo, owner, r reply := "" if closer, closeRequested := supersessionCloseDecision(command); closeRequested { - reply, err := c.closeSupersededPRDecision(ctx, displayRepo, owner, repo, pr, actor, closer) + reply, handled, err := c.closeSupersededPRDecision(ctx, displayRepo, owner, repo, pr, actor, closer) if err != nil { return false, err } - if err := c.postUnparkReply(ctx, owner, repo, number, candidate.GetID(), reply); err != nil { - return false, err + if handled { + if err := c.postUnparkReply(ctx, owner, repo, number, candidate.GetID(), reply); err != nil { + return false, err + } + result.Replies++ + return true, nil } - result.Replies++ - return true, nil } switch { case command.Kind == unparkCommandDecision && selfAuthored && head != "":