Skip to content

Commit aecaf86

Browse files
Karibhouclubandersonhivecommons-hive[bot]
authored
🐛 fix(automerge): hold same-issue PRs only at the merge gate and keep the pick (#17444)
* 🐛 fix(automerge): hold same-issue PRs only at the merge gate and keep the pick The one-PR-per-issue rule and the cross-lane claim-conflict gate ran over every open candidate on every sweep, regardless of CI or review state, and the winner flipped the moment either head had a red check. #17414/#17415 read "needs human decision · Let hive decide" with CI at 19/64 and swapped duplicate-of numbers as checks went red and green. - A same-issue PR is held (duplicate-of / claim-conflict) only once the lane last saw its head at the merge gate, the readiness a train seat needs. Short of the gate it is evaluated on its normal path, so its row reads CI / Review, and carries a turn the merge point checks: an undecided claim conflict holds it there; otherwise the first PR of the issue at the merge point takes the issue and the rest yield to it. - The pick is remembered per lane and issue and stands until the kept PR closes or is blocked for good (human merge, not mergeable, terminal required-check hold, permanent refusal); a red check does not flip it. - A merge-point hold verdict reads as merge-gate readiness, so the held PR is held at the gate from the next pass. - The delegate no longer promises a re-pick on the next pass. Hive-Run: #17439 Hive-Plan: issue-17439 options A+C (defer to the merge gate, stable winner) Hive-Spec: #17439#expected Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> * 🐛 fix(automerge): key the same-issue winner by base; keep it when the claimant read fell back Review follow-up on #17444: a forge-state read failure on an event-narrowed pass no longer forgets a still-open remembered winner, and v5/v6 siblings no longer share one mergeTrainIssueWinners entry. Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> --------- Signed-off-by: Karibhou <Karibhou@users.noreply.github.com> Co-authored-by: Karibhou <Karibhou@users.noreply.github.com> Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> Co-authored-by: hivecommons-hive[bot] <280983584+hivecommons-hive[bot]@users.noreply.github.com>
1 parent 4a960b4 commit aecaf86

11 files changed

Lines changed: 562 additions & 83 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- The merge train's one-PR-per-issue and same-issue claim-conflict holds now judge only heads at the merge gate ([#17439](https://github.com/hivecommons/hive/issues/17439)). A PR whose CI is still running or whose review is outstanding is evaluated as usual, so its tracker row reads CI / Review instead of "needs human decision · Let hive decide", and it is held at the merge point only if it gets there. The first same-issue PR at the merge point keeps the issue and that pick stands until it closes or is blocked for good, so a check going red mid-run no longer flips the winner and two PRs no longer wear each other's number as `duplicate-of`.

‎src/docs/dashboard-sections/activity-monitor.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ The tooltip lists the PRs ahead and the overlapping files. Waiting siblings are
289289

290290
**Queue position, countdown, stall** ([#15395](https://github.com/hivecommons/hive/issues/15395)). The queued chip is its place in the queue and nothing else — **Queued for merge · 2nd of 6 · ~4m** — so it reads the same whether or not a merge sweep is evaluating it: the sweep is the spinning ⬡ glyph and a *Checking now…* line in the hover, never a change of words (one label per chevron), and no state slug (`queued-behind`, `front`) ever reaches the page. The hover is the place sentence — *2nd of 6 in the merge queue · behind #15253 (soaking, 12m left) · ETA ~25m (2 ahead · head soaking 12m · next pass in 40s). Hive merges one at a time, in order.*; the head's reads *1st of 6 in the merge queue · merges next · ETA ~13m.* — or, when the head is on a person or on CI, the blocker instead of a clock: *… behind #15253 (waiting for a maintainer (untrusted author)) · no estimate until #15253 moves.* Every train state — the 1st included — lights the **Merge queue** column, whatever numeric stage the lane holds the head at (the train's facts are mirrored onto a head still at 050/060; hive's own #15400 once lit CI / Review beside its 2nd-place sibling) — a head under repair is no member (below) and lights CI / Review. The server adds to each member's `merge_train`: `length`, `head_number`, `head_state` (`needs-human` / `ci` / `soaking` / `ready` / `merging` / `fixing` / `unknown`), `head_reason` (plain words), `head_since`, `eta_s`, `eta_at`, `eta_basis`, `late`, `stalled`, `stalled_s`.
291291

292-
**One PR per issue** ([#15395](https://github.com/hivecommons/hive/issues/15395)). Two open PRs declaring the same `Fixes #N` never both ride the queue (hive's own #15402 and #15407, both for #15396, once sat side by side as "waiting its turn"). Before the trains are grouped the sweep keeps one PR per closing issue — a green head (not conflicting, not blocked on a red required check) beats a red one; among equals the earlier-opened wins — and holds the other out of every train with the stage reason `merge-train:duplicate-of#<winner>:<issue>/0:0/0:`. The held row's gate reads `duplicate-of:<winner>` (`merge_gate.duplicate_of`, `merge_gate.duplicate_issue`), its chip **Duplicate of #15402 · needs a pick** with no clock, it carries no `merge_train`, and it wears `needs-decision` so both PRs sit under their issue with the hold visible. *Let hive decide* on either row takes the existing supersede path (rule `one-pr-per-issue`): on the held PR it closes it as superseded by the kept one with a credit comment, through the same write gate as every delegate close; on the kept PR it releases it and names the duplicate the queue holds (or, if the kept PR has since gone red, only recommends the fix — the queue re-picks on its next pass, so an earlier red PR yields to a later green one).
292+
**One PR per issue** ([#15395](https://github.com/hivecommons/hive/issues/15395)). Two open PRs declaring the same `Fixes #N` never both ride the queue (hive's own #15402 and #15407, both for #15396, once sat side by side as "waiting its turn"). Before the trains are grouped the sweep keeps one PR per closing issue, judging heads at the merge gate only ([#17439](https://github.com/hivecommons/hive/issues/17439)): a same-issue PR whose CI is still running or whose review is outstanding carries no hold and no `needs-decision` — its row reads CI / Review like any other — and is held at the merge point if it gets there behind the issue's kept PR. The first PR at the merge point keeps the issue; among several already at the gate a green head (not conflicting) beats a conflicting one and then the earlier-opened wins; the pick then stands until the kept PR closes or is blocked for good, so a check that goes red mid-run never flips it. The other PR, once at the gate, is held out of every train with the stage reason `merge-train:duplicate-of#<winner>:<issue>/0:0/0:`. The held row's gate reads `duplicate-of:<winner>` (`merge_gate.duplicate_of`, `merge_gate.duplicate_issue`), its chip **Duplicate of #15402 · needs a pick** with no clock, it carries no `merge_train`, and it wears `needs-decision` so both PRs sit under their issue with the hold visible. *Let hive decide* on either row takes the existing supersede path (rule `one-pr-per-issue`): on the held PR it closes it as superseded by the kept one with a credit comment, through the same write gate as every delegate close; on the kept PR it releases it and names the duplicate the queue holds (or, if the kept PR has since gone red, only recommends the fix — the queue keeps its pick until the kept PR closes or is blocked for good).
293293

294294
- **Countdown to auto-merge.** `eta_s` is the member's own remaining soak, plus — for each PR ahead — its remaining soak or, with no clock of its own, one sweep (the lanes' floor cadence), plus the wait for the next sweep tick; `eta_basis` is the short reading ("2 ahead · head soaking 12m · next pass in 40s"). The chip's `· ~4m` counts down in place from `eta_at` on the same tick as the soak clock, and `eta_at` is anchored server-side to the member's first estimate at its position (re-anchored only when a fresh estimate runs more than a sweep longer), so a poll re-render never resets it. Past the estimate it reads *any moment now*; past twice the first estimate `late` is set and the chip reads *running late* with the estimate kept in the hover. Always a `~`: an estimate, not a promise. A head on a person, on CI or being fixed — or a member that is itself a person's — carries no estimate and the blocker instead.
295295
- **Merge queue popover.** The sticky bar's **Merge queue N** chip (led by the merge-sign icon) (hidden with no train) opens a popover listing every train in merge order — ordinal, #, title, state in words, ETA — with the head's blocker on the front row; same pattern as the **Merged** popover (click outside / Escape closes).

‎src/docs/hive-merge.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,23 @@ recorded trusted decision on a PR's head (the tracker's Approve, or any
12931293
what lifts the gate for that PR; closing the other ends it
12941294
([#15810](https://github.com/hivecommons/hive/issues/15810)).
12951295

1296+
Both rules judge **heads at the merge gate only**: a head the lane last saw
1297+
green with every required verdict recorded, the readiness a train seat needs
1298+
([#17439](https://github.com/hivecommons/hive/issues/17439)). A same-issue
1299+
PR whose CI is still running, or whose review is outstanding, is not held:
1300+
the lane evaluates it on its normal path, so its row reads CI running /
1301+
review pending, never *needs a pick* or *needs decision*. Should that head
1302+
reach the merge point it is held there instead of merging — on an undecided
1303+
claim conflict, or behind the PR that already holds its issue — and from the
1304+
next pass it is held at the gate like any other. Within one lane the first
1305+
PR at the merge point takes the issue; when several are already at the gate
1306+
the pick is the usual green-first, then earlier-opened. The pick then
1307+
**stands** until the kept PR closes or is blocked for good (a person merges
1308+
it, not mergeable, a terminal required-check hold, a permanent merge
1309+
refusal): a check that goes red mid-run no longer hands the issue to the
1310+
other PR, so two PRs never point at each other. The kept PR is remembered in
1311+
memory per lane and issue; a restart re-plans.
1312+
12961313
### Hive-authored superseding PRs auto-merge
12971314

12981315
Owner rule (@clubanderson): *"if a pr is superseded by a pr that a hive

‎src/docs/operator-reference/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ advanced into the merge path.
489489
| `auto_merge.require_current_base_default` | `false` | Hive-wide default of the current-base gate for repos in neither `require_current_base` nor `skip_current_base` (Settings → Features → Auto-merge → "Merge only on current base"). **Off** by default: the merge train already update-branches overlapping siblings, so the gate is being soaked per repo (hive's own hive first, via `require_current_base`) before any hive turns it on everywhere. When `true`, a behind head the App cannot update merges as it was tested, with one WARN per PR (`automerge current-base gate: … merging as tested`), so fork-heavy repos are not parked wholesale. |
490490
| `auto_merge.skip_current_base` | unset | Repo list (`owner/repo`) with an explicit **off** override for the current-base gate (Settings → Repos → "Merge only on current base" unchecked): a green head there merges even if the base has moved, whatever `require_current_base_default` says. An explicit `require_current_base` entry wins over this list. |
491491
| `auto_merge.merge_forward_trusted_human` | unset (off) | Repo list (`owner/repo`) where a `role/trusted` + `author/human` PR on a same-repo branch that GitHub reports as conflicting (`dirty`) gets the base merged into its head as one App-authored, signed-off merge commit via the GitHub merges API (no human commit is rewritten), then merges once CI is green on the new head. A real conflict pushes nothing and posts one `<!-- hive:merge-forward-conflict head=… -->` note naming the files both sides changed. Fork heads and dependency-bot branches (`renovate/*` head ref, or a Renovate, Dependabot or Mergeraptor author) are never written. Attempted once per head (#13636). |
492-
| `auto_merge.merge_train.enabled` | `true` | **Settings → Auto-merge → Merge train → Conflict-aware merge train.** Conflict-aware merge train in every automerge lane (hive, trusted-author, contributor) ([#15117](https://github.com/hivecommons/hive/issues/15117)). Each sweep groups the open approved / mergeable-pending PRs of one repo+base by overlapping changed files (`GET /pulls/{n}/files`, cached per head SHA) and lets ONE front per group merge — ordered readiness first: a head the lane last saw at the merge gate (green, approved, soaking or waiting on a sweep) leads, one it has not evaluated since its push comes next, one still at CI / Review trails, then clean-mergeable before behind, then oldest; recomputed every pass, so a member that turns green moves up and one that turns red moves back — while the others wait at stage/080 with lane reason `merge-train:queued-behind#<front>:<pos>/<size>:…` (tracker pill "In merge queue · 3 of 4 · ~4m", never a human stage, never `needs-rebase` as a human flag). The moment the front merges, every sibling whose branch is merely `behind` gets `PUT /pulls/{n}/update-branch`; a `dirty` one is handed to the fixer's rebase-resolve episode (`requested_by: merge-train`, the shared two-attempt guard of [#15104](https://github.com/hivecommons/hive/pull/15104); the fixer router still requires a head the hive can push to, so a fork without maintainer edits stays on the plain `conflicting` path for a person). A dependency bot's branch (`renovate/*` head ref, or a Renovate, Dependabot or Mergeraptor author) gets neither — behind or dirty, sibling or front, the bot rebases it itself (projectbluefin/utah#1097). A head the lane last saw blocked on its own (red required check, terminal hold, permanent merge refusal) never fronts, and a group whose best member is blocked imposes no ordering (fail-open) — nobody waits behind a PR that is not moving. One INFO line `merge train` per order change, not per cycle. The queue carries one PR per issue: when two open PRs declare the same `Fixes #N`, the sweep keeps one (green first, then the earlier-opened) and holds the other with lane reason `merge-train:duplicate-of#<winner>:<issue>/0:0/0:` (tracker pill "Duplicate of #15402 · needs a pick"; *Let hive decide* supersedes, [#15395](https://github.com/hivecommons/hive/issues/15395)). That pick sees one lane; when the two claimants sit in different lanes (the hive's own PR and a trusted human's PR for one issue) every lane holds its candidate with lane reason `merge-train:claim-conflict#<other>:<issue>/0:0/0:` and the log line `same-issue claim conflict undecided: #A vs #B`, so neither is admitted until a decision is recorded on one head or the other PR closes — the PR parks at 085, the chevron reads "held · duplicate of #B · needs decision", and the row's options name the concrete PRs; the hive recommends and never picks ([#15810](https://github.com/hivecommons/hive/issues/15810)). `false` restores independent per-PR evaluation. A PR GitHub reports conflicting on its own, or one at stage/072 returned-to-contributor (label or stage record), is never a member nor counted in the train's size and draws no train idle comment; a conflicting PR rides only while the train itself is repairing it (its rebase requested behind a merged front, loop guard not spent) ([#15395](https://github.com/hivecommons/hive/issues/15395)). |
492+
| `auto_merge.merge_train.enabled` | `true` | **Settings → Auto-merge → Merge train → Conflict-aware merge train.** Conflict-aware merge train in every automerge lane (hive, trusted-author, contributor) ([#15117](https://github.com/hivecommons/hive/issues/15117)). Each sweep groups the open approved / mergeable-pending PRs of one repo+base by overlapping changed files (`GET /pulls/{n}/files`, cached per head SHA) and lets ONE front per group merge — ordered readiness first: a head the lane last saw at the merge gate (green, approved, soaking or waiting on a sweep) leads, one it has not evaluated since its push comes next, one still at CI / Review trails, then clean-mergeable before behind, then oldest; recomputed every pass, so a member that turns green moves up and one that turns red moves back — while the others wait at stage/080 with lane reason `merge-train:queued-behind#<front>:<pos>/<size>:…` (tracker pill "In merge queue · 3 of 4 · ~4m", never a human stage, never `needs-rebase` as a human flag). The moment the front merges, every sibling whose branch is merely `behind` gets `PUT /pulls/{n}/update-branch`; a `dirty` one is handed to the fixer's rebase-resolve episode (`requested_by: merge-train`, the shared two-attempt guard of [#15104](https://github.com/hivecommons/hive/pull/15104); the fixer router still requires a head the hive can push to, so a fork without maintainer edits stays on the plain `conflicting` path for a person). A dependency bot's branch (`renovate/*` head ref, or a Renovate, Dependabot or Mergeraptor author) gets neither — behind or dirty, sibling or front, the bot rebases it itself (projectbluefin/utah#1097). A head the lane last saw blocked on its own (red required check, terminal hold, permanent merge refusal) never fronts, and a group whose best member is blocked imposes no ordering (fail-open) — nobody waits behind a PR that is not moving. One INFO line `merge train` per order change, not per cycle. The queue carries one PR per issue: when two open PRs declare the same `Fixes #N`, the sweep keeps one and holds the other with lane reason `merge-train:duplicate-of#<winner>:<issue>/0:0/0:` (tracker pill "Duplicate of #15402 · needs a pick"; *Let hive decide* supersedes, [#15395](https://github.com/hivecommons/hive/issues/15395)). That pick sees one lane; when the two claimants sit in different lanes (the hive's own PR and a trusted human's PR for one issue) every lane holds its candidate with lane reason `merge-train:claim-conflict#<other>:<issue>/0:0/0:` and the log line `same-issue claim conflict undecided: #A vs #B`, so neither is admitted until a decision is recorded on one head or the other PR closes — the PR parks at 085, the chevron reads "held · duplicate of #B · needs decision", and the row's options name the concrete PRs; the hive recommends and never picks ([#15810](https://github.com/hivecommons/hive/issues/15810)). Both rules hold only heads at the merge gate ([#17439](https://github.com/hivecommons/hive/issues/17439)): a same-issue PR still running CI or awaiting review is evaluated as usual (its row reads CI / Review) and is held at the merge point only if it gets there; the first PR at the merge point keeps the issue (among several already at the gate: green first, then the earlier-opened), and the pick stands until that PR closes or is blocked for good — a check going red mid-run does not flip it. `false` restores independent per-PR evaluation. A PR GitHub reports conflicting on its own, or one at stage/072 returned-to-contributor (label or stage record), is never a member nor counted in the train's size and draws no train idle comment; a conflicting PR rides only while the train itself is repairing it (its rebase requested behind a merged front, loop guard not spent) ([#15395](https://github.com/hivecommons/hive/issues/15395)). |
493493
| `auto_merge.merge_train.max_rebase_attempts` | `2`; `0` uses the default | **Settings → Auto-merge → Merge train → Rebase attempts per front.** Loop guard: how many rebase-resolve requests the train makes for one PR behind one front. Past it the PR moves to stage/085 with the pill "Needs human decision · rebase failed twice" and the decision option "Rebase, then merge as maintainer". |
494494
| `auto_merge.native_merge_queue.enabled` | `false` | Opt-in: once the lane's gates pass, enqueue the PR into GitHub's native merge queue (App auth only) instead of merging directly, and withdraw only Hive's own entries when the PR loses eligibility ([#15198](https://github.com/hivecommons/hive/issues/15198)). Off keeps the pre-queue behaviour everywhere, including the `merge-queue-required` park. See [native-merge-queue.md](../native-merge-queue.md). |
495495
| `auto_merge.native_merge_queue.repos` | unset (every watched repo once enabled) | `owner/repo` allow-list for the hand-off, matched case-insensitively. |

‎src/pkg/delegatedecision/policy.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -862,8 +862,9 @@ func decideClaimConflict(in Input, ref string) (Decision, bool) {
862862
}
863863

864864
// decideTrainDuplicate resolves a same-issue pair the merge queue split
865-
// (hivecommons/hive#15395). The queue's pick stands: a green head beats a
866-
// red one, the earlier-opened wins among equals. Deciding on the held PR
865+
// (hivecommons/hive#15395). The queue's pick stands: it picks between
866+
// heads at the merge gate and keeps the pick until the kept PR closes or
867+
// is blocked for good (hivecommons/hive#17439). Deciding on the held PR
867868
// closes it as superseded by the kept one, with a comment; deciding on
868869
// the kept PR releases it and names the duplicate the queue holds. A
869870
// paused repo only gets the recommendation.
@@ -896,7 +897,7 @@ func decideTrainDuplicate(in Input, ref string) (Decision, bool) {
896897
held := fmt.Sprintf("#%d", other.Number)
897898
if in.Conflicting || in.CIRed() || in.DCOMissing {
898899
return recommendOnly(RuleOnePRPerIssue,
899-
fmt.Sprintf("the merge queue kept %s over %s%s, but %s is not mergeable now (%s); the queue re-picks on its next pass", ref, held, issue, ref, mergeBlockers(in)),
900+
fmt.Sprintf("the merge queue kept %s over %s%s, but %s is not mergeable now (%s); the queue keeps it until it closes or is blocked for good", ref, held, issue, ref, mergeBlockers(in)),
900901
"fix "+ref+" ("+mergeBlockers(in)+"), or decide on "+held+" to let it supersede this one"), true
901902
}
902903
return releasePR(RuleOnePRPerIssue,

‎src/pkg/github/automerge/automerge_contributor_lane.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ func (c *Engine) SweepContributorLaneAutoMerges(ctx context.Context, opts AutoMe
470470
}
471471
result.Candidates++
472472
c.sweepClock.evaluating(displayRepo, number)
473-
event, reason, err := c.trySweepContributorLanePR(withMergeTrainCandidateHold(ctx, c.mergeTrainCandidateReason(train, number)), displayRepo, owner, repoName, number, policy)
473+
event, reason, err := c.trySweepContributorLanePR(c.mergeTrainEvalContext(ctx, train, number), displayRepo, owner, repoName, number, policy)
474474
c.sweepClock.evaluated()
475475
if err == nil {
476476
c.noteMergeTrainOutcome(stageLaneContributor, owner, repoName, number, pr.GetHead().GetSHA(), reason)

0 commit comments

Comments
 (0)