lib/forge-forgejo.sh — Forgejo's mergeable boolean is four conditions, not GitHub's tri-state: 17 of the 18 blocker:conflict writes on this forge were drafts #236

Closed
opened 2026-08-22 10:27:47 +00:00 by claude-bot-andresmgsl · 8 comments

Context

forge_pr_view translates Forgejo's PR object into the gh pr view shape the
reconciler was written against. GitHub's mergeable is a tri-state
(MERGEABLE / CONFLICTING / UNKNOWN). Forgejo's is a plain boolean, and
that boolean does not mean "no conflicts".

This instance is 8.0.3+gitea-1.22.0 (/api/v1/version, read 2026-08-22).
Upstream computes the field at
models/issues/pull.go:824-833:

// Mergeable returns if the pullrequest is mergeable.
func (pr *PullRequest) Mergeable(ctx context.Context) bool {
	// If a pull request isn't mergeable if it's:
	// - Being conflict checked.
	// - Has a conflict.
	// - Received a error while being conflict checked.
	// - Is a work-in-progress pull request.
	return pr.Status != PullRequestStatusChecking && pr.Status != PullRequestStatusConflict &&
		pr.Status != PullRequestStatusError && !pr.IsWorkInProgress(ctx)
}

Four conditions turn it false. One of them is a conflict.

The fourth is nothing but the title. IsWorkInProgress is
HasWorkInProgressPrefix(pr.Issue.Title)
(pull.go:733-749),
and the very same API object reports that predicate on its own as draft
(services/convert/pull.go:75,84):

Draft:     pr.IsWorkInProgress(ctx),
...
Mergeable: pr.Mergeable(ctx),

So on this forge draft: true implies mergeable: false by construction
two fields of one struct, one of which is a term of the other. And this fleet
marks a PR draft by putting WIP: in its title, on every build round, twice.

One more thing the boolean is not: absent. Mergeable bool
(modules/structs/pull.go:37)
is a plain non-pointer field with no omitempty, so the API always sends true
or false and never null for an open PR. The else "UNKNOWN" arm of our mapping
is unreachable here.

The defect

lib/forge-forgejo.sh:432-434
maps the boolean onto the tri-state by reading false as a conflict:

mergeable: (if $pr.mergeable == true then "MERGEABLE"
            elif $pr.mergeable == false then "CONFLICTING"
            else "UNKNOWN" end),

blockers() then emits the label at
:525:

case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac

The comment immediately above that line already argues this issue's case, for
the other backend
(:519-524):

UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for
about a minute after every merge while it recomputes, and flapping every open
PR on each merge would be worse than the bug.

That escape hatch is correct and it is dead code on Forgejo. Three of the
four false conditions land in CONFLICTING, and the value that triggers nothing
can never be produced.

The observed half — 17 of the 18 blocker:conflict writes on this forge are drafts

Measured 2026-08-22 over heavy-duty/crew pulls 55–89 — every blocker:conflict
event the forge has ever recorded. 18 adds, 18 removes, none standing.

One of the eighteen is a real conflict, and it is the control that proves the
CONFLICTING arm works.
!55, 2026-08-18T18:30:33Z: the PR was not a draft
(title dropped WIP: at 17:17:17Z) and had not been pushed since 17:14:36Z, an
hour and a quarter earlier. What moved was main — crew!54 merged at
18:26:25Z as e47c31c, four minutes and eight seconds before the label
appeared, which is the "another PR merges under this one" case
labels-sweep.yml
names. The builder resolved it the way the board asked: 040a7d30 at 19:10:44Z
is a merge commit whose parents are 5965032 and e47c31c — that same !54 merge
— and the label came off at 19:32:53Z. Nothing about this arm needs changing.

The other seventeen are draft windows. Each add falls inside a WIP:-titled
window, and none follows a push, a base move, or a merge underneath:

PR WIP: set label added latency WIP: dropped label removed
!55 15:45:47 15:57:46 11m59s 16:09:40 16:13:35
!55 16:55:03 17:02:26 7m23s 17:17:17 17:17:50
!57 18:14:39 18:25:15 10m36s 19:35:22 19:37:01
!60 03:19:54 03:25:53 5m59s 03:44:14 03:44:52
!60 04:19:17 04:35:20 16m03s 04:37:01 05:01:15
!61 05:03:50 05:09:29 5m39s 05:30:02 05:42:53
!63 10:33:21 10:54:12 20m51s 14:20:08 14:21:46
!63 16:19:22 16:33:38 14m16s 16:36:16 16:38:35
!64 17:24:28 17:28:09 3m41s 17:37:35 17:42:29
!64 18:22:53 18:29:01 6m08s 23:08:10 23:09:27
!77 opened WIP 16:06:30 16:26:26 19m56s 16:35:57 16:43:14
!78 17:51:37 18:07:59 16m22s 18:18:45 18:20:39
!79 21:22:48 21:34:47 11m59s 23:06:18 23:07:42
!80 opened WIP 00:27:28 00:32:59 5m31s 00:36:50 00:39:09
!87 opened WIP 08:17:39 08:19:33 1m54s 08:21:04 08:23:45
!87 10:00:58 10:02:49 1m51s 10:17:05 10:29:20
!88 opened WIP 08:21:47 08:23:38 1m51s 08:25:03 08:25:17

Twenty-one WIP: windows opened in this range. Seventeen drew the label; one
(!55, 18:51:26–19:21:35) spent its whole length under the real-conflict label
already standing; and three closed with no sweep inside them — 12m21s (!58),
7m28s (!59) and 1m52s (!80's second). Whether a window is labelled is only
whether a sweep ran in it: latency behind the title runs 1m51s to 20m51s.

A direct experiment, on crew!87, today

Not correlation. Between the last push at 10:09:29Z and the read at
10:17:46Z, no commit moved, base.sha stayed 50a6780a, merge_base
stayed 50a6780a, and the head stayed 891bd843:

read title draft mergeable
10:16Z WIP: fix: preserve complete Forgejo claim timelines true false
10:17:46Z fix: preserve complete Forgejo claim timelines false true

The only edit between the two rows is the title, at 10:17:05Z. And the merge
that false was describing is a fast-forward — merge_base == base.sha == main,
so the head already contains main and a conflict is arithmetically impossible
at both readings. Confirmed offline:
git merge-base build/84-pagination-claim origin/main returns 50a6780a, which
is origin/main.

blocker:conflict stood on crew!87 over a branch git says cannot conflict, from
10:02:49Z to 10:29:20Z — 26m31s, and 12m15s of that was after the WIP:
prefix came off at 10:17:05Z. It is recorded on that PR, at
crew!87 10:37Z.

Stamped 2026-08-22T10:35Z by triage. As minted, ten minutes earlier, this
paragraph and the last row of the table above said the label was standing.
The reconciler took it off at 10:29:20Z, which closed the seventeenth window and
made this the first row in the table whose whole life is measured rather than
still running. Nothing about the defect changes — the label was false for
every one of those 26m31s, for the reason given above, and the count moves from
"18 adds, 17 removes, one standing" to 18 and 18. The window closing is what this
defect does on its own; it is not the defect being fixed.

What it costs

A blocker is the board's word for the builder owes work before this can move.
BUILDER.md
makes handoff conditional on "no blocker:* standing" and
:278
spells this one out as "conflicts rebased". So for the whole of every draft
window — which is the whole of every build round and every fix round — the board
tells the builder it owes a rebase, and tells a reviewer and the operator that
the PR does not merge. Both are false, and the rebase the board asks for would
rewrite pushed history on a branch that is already a fast-forward.

It is not currently gating an engine: crew's builder launches rebase sessions
only on (.isDraft | not) and .mergeable == "CONFLICTING"
(shared/lib/duty-builder.sh:2437-2447), and that draft filter happens to
exclude exactly the condition, because Forgejo's draft and the WIP term of
mergeable are the same predicate. That is luck: the filter's stated purpose is
"a panel must never be requested on a draft", not "the boolean lies about
drafts". The cost today is the reading, and the reading is what a blocker label
is for.

The latent half — the conflict-check window

PullRequestStatusChecking and PullRequestStatusError are the other two false
conditions, and no draft filter covers them. A non-draft PR whose conflict check
is still queued reports mergeable: false, which this mapping calls
CONFLICTING — precisely the flap the :519-524 comment refuses to cause on
GitHub.

Not observed, and this issue does not claim it as a sighting. Of the 18 adds
in the range above, seventeen are draft windows and one is the real !55 conflict;
not one is a check window. It is in scope because it is the same one-line
mapping, and because the value the code already reserves for "do not know" is
currently unreachable — which is also why arm 3 below exists.

The fix

Report CONFLICTING only for what Forgejo can actually distinguish as a
conflict, and UNKNOWN — the value that triggers nothing — for the rest.
forge_pr_view already holds every field this needs; nothing new is fetched.

Spec

Replace the mapping at lib/forge-forgejo.sh:432-434 with four ordered arms:

mergeable: (if $pr.draft == true then "UNKNOWN"
            elif $pr.mergeable == true then "MERGEABLE"
            elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
            else "CONFLICTING" end),

Each arm answers a different question, and the ordering is load-bearing:

  1. draft first. WIP suppresses the boolean unconditionally, so on a draft
    the boolean carries no merge information at all and UNKNOWN is the only
    honest report. It is deliberately not MERGEABLE: a draft can genuinely
    conflict, and certifying the unmeasured as green is the exact shape of #136.
    This arm must precede the mergeable == true arm so that the reading is
    "we did not measure", not "we measured and it passed".
  2. mergeable == trueMERGEABLE, unchanged. true is reachable only
    when the check ran and passed and the PR is not WIP.
  3. A non-draft false whose base tip is already the merge base → UNKNOWN.
    The head contains the base tip, so the merge is a fast-forward and
    PullRequestStatusConflict is unreachable; false there is Checking or
    Error. merge_base and base.sha are both already in the object being
    read, on the single-PR route and the list route alike (measured on this
    instance 2026-08-22 across crew's three open PRs). This arm can never mask a
    real conflict, because the condition it tests makes one impossible.
  4. Otherwise → CONFLICTING, unchanged.

Arm 3 narrows the check window; it does not close it. A branch that is
behind its base and merely mid-check still reads CONFLICTING. Nothing in
Forgejo's API distinguishes that from a real conflict — there is no status enum,
no conflicted_files, no rerun handle
(modules/structs/pull.go
in full) — and closing it would need a merge trial the shim has no repository to
run. Saying so here is the point: the remaining window is known, bounded, and
not a thing to discover twice.

lib/forge-github.sh is not touched. It reads gh's real tri-state, where all
four conditions are already distinct, and this issue changes no policy above the
backend.

Out of scope, deliberately: blockers(), decide_state, round_state, and
every other consumer of MERGEABLE. All are correct given a correctly reported
value; this issue fixes the report.

Tasks

  • Red-first: in test/forge-backends.test.sh, beside the existing
    forge_pr_view block at
    :584-618,
    add a draft fixture (draft:true, mergeable:false) asserting UNKNOWN.
    It fails today with CONFLICTING.
  • Add a fast-forward check-window fixture (draft:false, mergeable:false, merge_base == base.sha) asserting UNKNOWN. It fails today with
    CONFLICTING.
  • Add the real-conflict control (draft:false, mergeable:false, merge_base != base.sha) asserting CONFLICTING. It passes today.
  • Add the arm-ordering fixture: draft:true, mergeable:true yields
    UNKNOWN. Note in the fixture that this input is unreachable on the forge
    Mergeable() ANDs in !IsWorkInProgress — and that the case exists to
    pin arm 1 ahead of arm 2, not to model a real read.
  • Apply the four-arm mapping at lib/forge-forgejo.sh:432-434, carrying a
    comment that names the four upstream conditions and why draft is read
    first.
  • Assert the GitHub backend is untouched: lib/forge-github.sh's existing
    forge_pr_view assertions pass unedited.
  • Add changelog.d/<this issue>.md.
  • Full suite and sanctioned shellcheck green at the PR head.

Acceptance criteria

  • forge_pr_view returns UNKNOWN for a draft PR reporting
    mergeable: false. Recorded RED first: the same fixture returns
    CONFLICTING on the unfixed tree, and the PR shows both outputs.
  • forge_pr_view returns UNKNOWN for a non-draft PR reporting
    mergeable: false whose merge_base equals base.sha. Recorded RED
    first against the unfixed tree in the same way.
  • forge_pr_view still returns CONFLICTING for a non-draft
    mergeable: false whose merge_base differs from base.sha, and still
    returns MERGEABLE for mergeable: true — the existing assertion at
    :617-618 passes unedited.
  • A draft carrying mergeable: true returns UNKNOWN, proving arm 1
    precedes arm 2.
  • lib/forge-github.sh has no diff, and no assertion currently in
    test/forge-backends.test.sh is deleted or weakened; the file's assertion
    count rises.
  • The full suite and sanctioned shellcheck are green at the PR head.

Test plan

Home is test/forge-backends.test.sh — no network, a stubbed forge_api that
serves fixture JSON per route, which is exactly the shape this defect needs. Its
forge_pr_view block at :584-618 already stubs
{"head":{"sha":"abc"},"mergeable":true} and asserts the mapped string; extend
that stub with draft, merge_base and base.sha rather than starting a new
block.

Cases that must fail before and pass after:

  1. draft:true, mergeable:falseUNKNOWN. Fails today with CONFLICTING.
  2. draft:false, mergeable:false, merge_base == base.shaUNKNOWN.
    Fails today with CONFLICTING.

Cases that must keep passing untouched:

  1. draft:false, mergeable:trueMERGEABLE (the assertion already at :617).
  2. draft:false, mergeable:false, merge_base != base.shaCONFLICTING.
  3. Every lib/forge-github.sh assertion in the file.

Ordering evidence rather than a behaviour claim:

  1. draft:true, mergeable:trueUNKNOWN. Unreachable on the forge; it exists
    so that reordering arms 1 and 2 turns the suite red.

Live control — the crew!87 specimen is spent; the mechanism is not. crew!87
merged 2026-08-22T10:56:11Z and now reads draft:false, mergeable:true at
891bd843 for good, so the before/after pair can no longer be taken off it. What
made it a control was never that pull request: Mergeable() takes
IsWorkInProgress() as a term
(pull.go:824-833)
and Draft reports the same predicate on the same struct
(convert/pull.go:75,84),
so any WIP: title edit on any open pull request flips mergeable with
the head, merge_base and base.sha all fixed. Read
/api/v1/repos/heavy-duty/crew/pulls/{n} either side of the next one.

Read 2026-08-23, that next one arrived three minutes after this issue was
claimed, and it is this issue's own PR. !242 opened WIP:-titled at
2026-08-23T17:40:56Z; forgejo-actions wrote blocker:conflict on it at
17:41:21Z; the WIP: prefix came off at 17:45:44Z — a title edit, with
no push after 17:41:52Z — and at 17:49:54Z the PR read
draft:false, mergeable:true at 8f9f7e56 with blocker:conflict still
standing
. All three of its commits sit on top of base 1f5dd39a
(merge_base == base.sha at the read), so a conflict was arithmetically
impossible at every head it has had: the label was false when written and still
false 8m33s later.

What this specimen cannot say, and crew!87 could: 25 seconds after an open,
PullRequestStatusChecking is not excluded, so the false behind that write is
not attributable to the WIP term alone. It does not need to be — draft was
true at the write, so arm 1 answers it, and if the false was in fact the
conflict-check window then arm 3 answers it instead, on the same fixed
merge_base == base.sha. That is the latent half above, which this issue records
as unobserved; this instance does not make it observed, it makes it
unexcluded. Either way the four-arm mapping emits UNKNOWN and no label.
Nothing in the acceptance criteria depends on any of it: every case above is
hermetic against stubbed payloads, and this control is corroboration, not evidence.

Dependencies

None — nothing blocks this issue. lib/forge-forgejo.sh and
test/forge-backends.test.sh are its deliverables, and two open issues also
carry them: #238 and #240, both blocked — joined 2026-08-23 by #243
(blocked), whose forge_pr_view fix writes the same two files. That costs this
issue no edge.
Under #288 the collision edge is owed by the newer issue to the newest open
carrier, so #238 declares it here, #240 declares it on #238, and #243 declares it
on #240; the chain #236#238#240#243 sequences all four, this issue goes
first as the one already open, and it declares nothing of its own. No other open
issue touches either
file: #235 is actions/labels-reconcile/labels-reconcile.sh and
test/labels-reconcile.test.sh, #234 is issueflow-reconcile.sh and its test
(#230, the third carrier of that pair, closed 2026-08-23), #231 is VERSION /
CHANGELOG.md / docs/UPSTREAM-SYNC.md / the workflow pins, #241 is
.github/workflows/labels.yml, #228 is the sync epic.

Blocks #238, minted 2026-08-22. #238 adds a forge_pr_review_requests verb
to both backends, so it changes these same two files as well as #235's two, and
it declares a collision edge on each carrier. The edge here is collision only —
the mergeable mapping and the review-request set are unrelated values that
happen to share a file — so either order would have been correct on the merits;
this issue is the one already open, so it goes first. Nothing in this issue's
spec changes because of it.

The repair is deliberately kept in the backend and out of
labels-reconcile.sh.
Putting it where the value is manufactured is right on
its own terms — one wrong translation, one place — and it also means this and
#235, the two Forgejo-vocabulary fixes now open, touch disjoint files and can
land in either order with no rebase between them. Moving any part of this into
blockers() would create exactly the collision that ordering avoids.

Not a child of #228 and it does not gate #231: the sync epic adopts upstream
0.6.1–0.6.3, and this is forge-side debt found by a consumer — the same standing
#234 and #235 took. No release-window edge either: the lead stood the premature 0.6.2
window down 2026-08-17T23:33:02Z, and it returned release to #231 on
2026-08-23 once #231's own gate emptied and the sweep flipped it to ready at
17:00:57Z — that flip was the lead's stated return condition. The label back
on #231 still stands no window.
Under #343 a release issue's membership lives
in a ## Members record and there is no fallback to the gate; #231 has no
such record, so it enumerates no members, is not a window carrier, and draws no
window flag. There is no window to be a member of (label events re-read
2026-08-23T17:13Z).

Consumer note: crew runs this reconciler via
heavy-duty/ceremony/.github/workflows/labels.yml@0.6.1 in labels.yml and
labels-sweep.yml, so crew picks the fix up at its next pin bump — already
recorded as #231's spec item 5. crew also ships its own independent Forgejo
shim carrying the same one-line mapping, with different consumers (forge_pr_list
as well as forge_pr_view, feeding the rebase launcher and the handoff gate).
That is crew's file and crew#90, minted alongside this issue. Neither waits
on the other: different repository, different file, different consumers. The
seventeen false labels counted above are this issue's cost; crew#90's is a
rebase session that could be launched on a PR whose conflict check is merely
in flight, which has not been observed and is recorded there as latent.

## Context `forge_pr_view` translates Forgejo's PR object into the `gh pr view` shape the reconciler was written against. GitHub's `mergeable` is a **tri-state** (`MERGEABLE` / `CONFLICTING` / `UNKNOWN`). Forgejo's is a plain **boolean**, and that boolean does not mean "no conflicts". This instance is `8.0.3+gitea-1.22.0` (`/api/v1/version`, read 2026-08-22). Upstream computes the field at [`models/issues/pull.go:824-833`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/models/issues/pull.go#L824-L833): ```go // Mergeable returns if the pullrequest is mergeable. func (pr *PullRequest) Mergeable(ctx context.Context) bool { // If a pull request isn't mergeable if it's: // - Being conflict checked. // - Has a conflict. // - Received a error while being conflict checked. // - Is a work-in-progress pull request. return pr.Status != PullRequestStatusChecking && pr.Status != PullRequestStatusConflict && pr.Status != PullRequestStatusError && !pr.IsWorkInProgress(ctx) } ``` Four conditions turn it false. **One** of them is a conflict. The fourth is nothing but the title. `IsWorkInProgress` is `HasWorkInProgressPrefix(pr.Issue.Title)` ([`pull.go:733-749`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/models/issues/pull.go#L733-L749)), and the very same API object reports that predicate on its own as `draft` ([`services/convert/pull.go:75,84`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/services/convert/pull.go#L75-L84)): ```go Draft: pr.IsWorkInProgress(ctx), ... Mergeable: pr.Mergeable(ctx), ``` So on this forge **`draft: true` implies `mergeable: false` by construction** — two fields of one struct, one of which is a term of the other. And this fleet marks a PR draft by putting `WIP:` in its title, on every build round, twice. One more thing the boolean is not: absent. `Mergeable bool` ([`modules/structs/pull.go:37`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/modules/structs/pull.go#L37)) is a plain non-pointer field with no `omitempty`, so the API always sends `true` or `false` and never null for an open PR. The `else "UNKNOWN"` arm of our mapping is unreachable here. ## The defect [`lib/forge-forgejo.sh:432-434`](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/lib/forge-forgejo.sh#L432-L434) maps the boolean onto the tri-state by reading false as a conflict: ```jq mergeable: (if $pr.mergeable == true then "MERGEABLE" elif $pr.mergeable == false then "CONFLICTING" else "UNKNOWN" end), ``` `blockers()` then emits the label at [`:525`](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/actions/labels-reconcile/labels-reconcile.sh#L525): ```bash case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac ``` The comment immediately above that line already argues this issue's case, for the other backend ([`:519-524`](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/actions/labels-reconcile/labels-reconcile.sh#L519-L524)): > UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for > about a minute after every merge while it recomputes, and flapping every open > PR on each merge would be worse than the bug. That escape hatch is correct and it is **dead code on Forgejo**. Three of the four false conditions land in `CONFLICTING`, and the value that triggers nothing can never be produced. ### The observed half — 17 of the 18 `blocker:conflict` writes on this forge are drafts Measured 2026-08-22 over `heavy-duty/crew` pulls 55–89 — every `blocker:conflict` event the forge has ever recorded. **18 adds, 18 removes, none standing.** **One of the eighteen is a real conflict, and it is the control that proves the `CONFLICTING` arm works.** !55, `2026-08-18T18:30:33Z`: the PR was not a draft (title dropped `WIP:` at 17:17:17Z) and had not been pushed since 17:14:36Z, an hour and a quarter earlier. What moved was `main` — crew!54 merged at **18:26:25Z** as `e47c31c`, four minutes and eight seconds before the label appeared, which is the "another PR merges under this one" case [labels-sweep.yml](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/.github/workflows/self-labels-sweep.yml#L11-L12) names. The builder resolved it the way the board asked: `040a7d30` at 19:10:44Z is a merge commit whose parents are `5965032` and `e47c31c` — that same !54 merge — and the label came off at 19:32:53Z. Nothing about this arm needs changing. **The other seventeen are draft windows.** Each add falls inside a `WIP:`-titled window, and none follows a push, a base move, or a merge underneath: | PR | `WIP:` set | label added | latency | `WIP:` dropped | label removed | |---|---|---|---|---|---| | !55 | 15:45:47 | 15:57:46 | 11m59s | 16:09:40 | 16:13:35 | | !55 | 16:55:03 | 17:02:26 | 7m23s | 17:17:17 | 17:17:50 | | !57 | 18:14:39 | 18:25:15 | 10m36s | 19:35:22 | 19:37:01 | | !60 | 03:19:54 | 03:25:53 | 5m59s | 03:44:14 | 03:44:52 | | !60 | 04:19:17 | 04:35:20 | 16m03s | 04:37:01 | 05:01:15 | | !61 | 05:03:50 | 05:09:29 | 5m39s | 05:30:02 | 05:42:53 | | !63 | 10:33:21 | 10:54:12 | 20m51s | 14:20:08 | 14:21:46 | | !63 | 16:19:22 | 16:33:38 | 14m16s | 16:36:16 | 16:38:35 | | !64 | 17:24:28 | 17:28:09 | 3m41s | 17:37:35 | 17:42:29 | | !64 | 18:22:53 | 18:29:01 | 6m08s | 23:08:10 | 23:09:27 | | !77 | opened WIP 16:06:30 | 16:26:26 | 19m56s | 16:35:57 | 16:43:14 | | !78 | 17:51:37 | 18:07:59 | 16m22s | 18:18:45 | 18:20:39 | | !79 | 21:22:48 | 21:34:47 | 11m59s | 23:06:18 | 23:07:42 | | !80 | opened WIP 00:27:28 | 00:32:59 | 5m31s | 00:36:50 | 00:39:09 | | !87 | opened WIP 08:17:39 | 08:19:33 | 1m54s | 08:21:04 | 08:23:45 | | !87 | 10:00:58 | 10:02:49 | 1m51s | 10:17:05 | 10:29:20 | | !88 | opened WIP 08:21:47 | 08:23:38 | 1m51s | 08:25:03 | 08:25:17 | Twenty-one `WIP:` windows opened in this range. Seventeen drew the label; one (!55, 18:51:26–19:21:35) spent its whole length under the real-conflict label already standing; and three closed with no sweep inside them — 12m21s (!58), 7m28s (!59) and 1m52s (!80's second). Whether a window is labelled is only whether a sweep ran in it: latency behind the title runs 1m51s to 20m51s. ### A direct experiment, on crew!87, today Not correlation. Between the last push at **10:09:29Z** and the read at **10:17:46Z**, no commit moved, `base.sha` stayed `50a6780a`, `merge_base` stayed `50a6780a`, and the head stayed `891bd843`: | read | title | `draft` | `mergeable` | |---|---|---|---| | 10:16Z | `WIP: fix: preserve complete Forgejo claim timelines` | `true` | **`false`** | | 10:17:46Z | `fix: preserve complete Forgejo claim timelines` | `false` | **`true`** | The only edit between the two rows is the title, at 10:17:05Z. And the merge that `false` was describing is a fast-forward — `merge_base == base.sha == main`, so the head already contains `main` and a conflict is arithmetically impossible at *both* readings. Confirmed offline: `git merge-base build/84-pagination-claim origin/main` returns `50a6780a`, which is `origin/main`. `blocker:conflict` stood on crew!87 over a branch git says cannot conflict, from 10:02:49Z to **10:29:20Z — 26m31s**, and 12m15s of that was after the `WIP:` prefix came off at 10:17:05Z. It is recorded on that PR, at [crew!87 10:37Z](https://forgejo.heavyduty.builders/heavy-duty/crew/pulls/87#issuecomment-12428). *Stamped 2026-08-22T10:35Z by triage.* As minted, ten minutes earlier, this paragraph and the last row of the table above said the label was **standing**. The reconciler took it off at 10:29:20Z, which closed the seventeenth window and made this the first row in the table whose whole life is measured rather than still running. **Nothing about the defect changes** — the label was false for every one of those 26m31s, for the reason given above, and the count moves from "18 adds, 17 removes, one standing" to 18 and 18. The window closing is what this defect does on its own; it is not the defect being fixed. ### What it costs A blocker is the board's word for *the builder owes work before this can move*. [BUILDER.md](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/BUILDER.md#L37) makes handoff conditional on **"no `blocker:*` standing"** and [:278](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/BUILDER.md#L278) spells this one out as *"conflicts rebased"*. So for the whole of every draft window — which is the whole of every build round and every fix round — the board tells the builder it owes a rebase, and tells a reviewer and the operator that the PR does not merge. Both are false, and the rebase the board asks for would rewrite pushed history on a branch that is already a fast-forward. It is not currently *gating* an engine: crew's builder launches rebase sessions only on `(.isDraft | not) and .mergeable == "CONFLICTING"` (`shared/lib/duty-builder.sh:2437-2447`), and that draft filter happens to exclude exactly the condition, because Forgejo's `draft` and the WIP term of `mergeable` are the same predicate. That is luck: the filter's stated purpose is "a panel must never be requested on a draft", not "the boolean lies about drafts". The cost today is the reading, and the reading is what a blocker label is for. ### The latent half — the conflict-check window `PullRequestStatusChecking` and `PullRequestStatusError` are the other two false conditions, and no draft filter covers them. A non-draft PR whose conflict check is still queued reports `mergeable: false`, which this mapping calls `CONFLICTING` — precisely the flap the `:519-524` comment refuses to cause on GitHub. **Not observed, and this issue does not claim it as a sighting.** Of the 18 adds in the range above, seventeen are draft windows and one is the real !55 conflict; not one is a check window. It is in scope because it is the same one-line mapping, and because the value the code already reserves for "do not know" is currently unreachable — which is also why arm 3 below exists. ## The fix Report `CONFLICTING` only for what Forgejo can actually distinguish as a conflict, and `UNKNOWN` — the value that triggers nothing — for the rest. `forge_pr_view` already holds every field this needs; nothing new is fetched. ## Spec Replace the mapping at `lib/forge-forgejo.sh:432-434` with four ordered arms: ```jq mergeable: (if $pr.draft == true then "UNKNOWN" elif $pr.mergeable == true then "MERGEABLE" elif $pr.merge_base == $pr.base.sha then "UNKNOWN" else "CONFLICTING" end), ``` Each arm answers a different question, and the ordering is load-bearing: 1. **`draft` first.** WIP suppresses the boolean unconditionally, so on a draft the boolean carries no merge information at all and `UNKNOWN` is the only honest report. It is deliberately **not** `MERGEABLE`: a draft can genuinely conflict, and certifying the unmeasured as green is the exact shape of #136. This arm must precede the `mergeable == true` arm so that the reading is "we did not measure", not "we measured and it passed". 2. **`mergeable == true` → `MERGEABLE`**, unchanged. `true` is reachable only when the check ran and passed and the PR is not WIP. 3. **A non-draft false whose base tip is already the merge base → `UNKNOWN`.** The head contains the base tip, so the merge is a fast-forward and `PullRequestStatusConflict` is unreachable; false there is `Checking` or `Error`. `merge_base` and `base.sha` are both already in the object being read, on the single-PR route and the list route alike (measured on this instance 2026-08-22 across crew's three open PRs). This arm can never mask a real conflict, because the condition it tests makes one impossible. 4. **Otherwise → `CONFLICTING`**, unchanged. **Arm 3 narrows the check window; it does not close it.** A branch that is behind its base and merely mid-check still reads `CONFLICTING`. Nothing in Forgejo's API distinguishes that from a real conflict — there is no status enum, no `conflicted_files`, no rerun handle ([`modules/structs/pull.go`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/modules/structs/pull.go) in full) — and closing it would need a merge trial the shim has no repository to run. Saying so here is the point: the remaining window is known, bounded, and not a thing to discover twice. `lib/forge-github.sh` is not touched. It reads `gh`'s real tri-state, where all four conditions are already distinct, and this issue changes no policy above the backend. **Out of scope, deliberately:** `blockers()`, `decide_state`, `round_state`, and every other consumer of `MERGEABLE`. All are correct given a correctly reported value; this issue fixes the report. ## Tasks - [ ] Red-first: in `test/forge-backends.test.sh`, beside the existing `forge_pr_view` block at [:584-618](https://forgejo.heavyduty.builders/heavy-duty/ceremony/src/commit/27f702a0189eaf099127b59e090ca6139acb9d75/test/forge-backends.test.sh#L584-L618), add a draft fixture (`draft:true, mergeable:false`) asserting `UNKNOWN`. It fails today with `CONFLICTING`. - [ ] Add a fast-forward check-window fixture (`draft:false, mergeable:false, merge_base == base.sha`) asserting `UNKNOWN`. It fails today with `CONFLICTING`. - [ ] Add the real-conflict control (`draft:false, mergeable:false, merge_base != base.sha`) asserting `CONFLICTING`. It passes today. - [ ] Add the arm-ordering fixture: `draft:true, mergeable:true` yields `UNKNOWN`. Note in the fixture that this input is unreachable on the forge — `Mergeable()` ANDs in `!IsWorkInProgress` — and that the case exists to pin arm 1 ahead of arm 2, not to model a real read. - [ ] Apply the four-arm mapping at `lib/forge-forgejo.sh:432-434`, carrying a comment that names the four upstream conditions and why `draft` is read first. - [ ] Assert the GitHub backend is untouched: `lib/forge-github.sh`'s existing `forge_pr_view` assertions pass unedited. - [ ] Add `changelog.d/<this issue>.md`. - [ ] Full suite and sanctioned shellcheck green at the PR head. ## Acceptance criteria - [x] `forge_pr_view` returns `UNKNOWN` for a draft PR reporting `mergeable: false`. Recorded RED first: the same fixture returns `CONFLICTING` on the unfixed tree, and the PR shows both outputs. - [x] `forge_pr_view` returns `UNKNOWN` for a non-draft PR reporting `mergeable: false` whose `merge_base` equals `base.sha`. Recorded RED first against the unfixed tree in the same way. - [x] `forge_pr_view` still returns `CONFLICTING` for a non-draft `mergeable: false` whose `merge_base` differs from `base.sha`, and still returns `MERGEABLE` for `mergeable: true` — the existing assertion at :617-618 passes unedited. - [x] A draft carrying `mergeable: true` returns `UNKNOWN`, proving arm 1 precedes arm 2. - [x] `lib/forge-github.sh` has no diff, and no assertion currently in `test/forge-backends.test.sh` is deleted or weakened; the file's assertion count rises. - [x] The full suite and sanctioned shellcheck are green at the PR head. ## Test plan Home is `test/forge-backends.test.sh` — no network, a stubbed `forge_api` that serves fixture JSON per route, which is exactly the shape this defect needs. Its `forge_pr_view` block at :584-618 already stubs `{"head":{"sha":"abc"},"mergeable":true}` and asserts the mapped string; extend that stub with `draft`, `merge_base` and `base.sha` rather than starting a new block. Cases that must fail before and pass after: 1. `draft:true, mergeable:false` → `UNKNOWN`. **Fails today with `CONFLICTING`.** 2. `draft:false, mergeable:false, merge_base == base.sha` → `UNKNOWN`. **Fails today with `CONFLICTING`.** Cases that must keep passing untouched: 3. `draft:false, mergeable:true` → `MERGEABLE` (the assertion already at :617). 4. `draft:false, mergeable:false, merge_base != base.sha` → `CONFLICTING`. 5. Every `lib/forge-github.sh` assertion in the file. Ordering evidence rather than a behaviour claim: 6. `draft:true, mergeable:true` → `UNKNOWN`. Unreachable on the forge; it exists so that reordering arms 1 and 2 turns the suite red. Live control — **the crew!87 specimen is spent; the mechanism is not.** crew!87 merged 2026-08-22T10:56:11Z and now reads `draft:false, mergeable:true` at `891bd843` for good, so the before/after pair can no longer be taken off it. What made it a control was never that pull request: `Mergeable()` takes `IsWorkInProgress()` as a term ([`pull.go:824-833`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/models/issues/pull.go#L824-L833)) and `Draft` reports the same predicate on the same struct ([`convert/pull.go:75,84`](https://codeberg.org/forgejo/forgejo/src/tag/v8.0.3/services/convert/pull.go#L75-L84)), so **any** `WIP:` title edit on **any** open pull request flips `mergeable` with the head, `merge_base` and `base.sha` all fixed. Read `/api/v1/repos/heavy-duty/crew/pulls/{n}` either side of the next one. Read 2026-08-23, that next one arrived three minutes after this issue was claimed, and it is **this issue's own PR**. !242 opened `WIP:`-titled at 2026-08-23T17:40:56Z; `forgejo-actions` wrote `blocker:conflict` on it at **17:41:21Z**; the `WIP:` prefix came off at **17:45:44Z** — a title edit, with no push after 17:41:52Z — and at **17:49:54Z** the PR read `draft:false, mergeable:true` at `8f9f7e56` with **`blocker:conflict` still standing**. All three of its commits sit on top of base `1f5dd39a` (`merge_base == base.sha` at the read), so a conflict was arithmetically impossible at every head it has had: the label was false when written and still false 8m33s later. What this specimen cannot say, and crew!87 could: 25 seconds after an open, `PullRequestStatusChecking` is not excluded, so the `false` behind that write is not attributable to the WIP term alone. It does not need to be — `draft` was `true` at the write, so **arm 1** answers it, and if the `false` was in fact the conflict-check window then **arm 3** answers it instead, on the same fixed `merge_base == base.sha`. That is the latent half above, which this issue records as unobserved; this instance does not make it observed, it makes it unexcluded. Either way the four-arm mapping emits `UNKNOWN` and no label. **Nothing in the acceptance criteria depends on any of it**: every case above is hermetic against stubbed payloads, and this control is corroboration, not evidence. ## Dependencies **None — nothing blocks this issue.** `lib/forge-forgejo.sh` and `test/forge-backends.test.sh` are its deliverables, and two open issues also carry them: **#238** and **#240**, both `blocked` — joined 2026-08-23 by **#243** (`blocked`), whose `forge_pr_view` fix writes the same two files. That costs this issue no edge. Under #288 the collision edge is owed by the **newer** issue to the newest open carrier, so #238 declares it here, #240 declares it on #238, and #243 declares it on #240; the chain #236 → #238 → #240 → #243 sequences all four, this issue goes first as the one already open, and it declares nothing of its own. No other open issue touches either file: #235 is `actions/labels-reconcile/labels-reconcile.sh` and `test/labels-reconcile.test.sh`, #234 is `issueflow-reconcile.sh` and its test (#230, the third carrier of that pair, closed 2026-08-23), #231 is `VERSION` / `CHANGELOG.md` / `docs/UPSTREAM-SYNC.md` / the workflow pins, #241 is `.github/workflows/labels.yml`, #228 is the sync epic. **Blocks #238**, minted 2026-08-22. #238 adds a `forge_pr_review_requests` verb to both backends, so it changes these same two files as well as #235's two, and it declares a collision edge on each carrier. The edge here is collision only — the `mergeable` mapping and the review-request set are unrelated values that happen to share a file — so either order would have been correct on the merits; this issue is the one already open, so it goes first. Nothing in this issue's spec changes because of it. **The repair is deliberately kept in the backend and out of `labels-reconcile.sh`.** Putting it where the value is manufactured is right on its own terms — one wrong translation, one place — and it also means this and #235, the two Forgejo-vocabulary fixes now open, touch disjoint files and can land in either order with no rebase between them. Moving any part of this into `blockers()` would create exactly the collision that ordering avoids. Not a child of #228 and it does not gate #231: the sync epic adopts upstream 0.6.1–0.6.3, and this is forge-side debt found by a consumer — the same standing #234 and #235 took. No release-window edge either: the lead stood the premature 0.6.2 window down 2026-08-17T23:33:02Z, and it returned `release` to #231 on 2026-08-23 once #231's own gate emptied and the sweep flipped it to `ready` at 17:00:57Z — that flip was the lead's stated return condition. **The label back on #231 still stands no window.** Under #343 a release issue's membership lives in a `## Members` record and there is **no fallback to the gate**; #231 has no such record, so it enumerates no members, is not a window carrier, and draws no window flag. There is no window to be a member of (label events re-read 2026-08-23T17:13Z). Consumer note: crew runs this reconciler via `heavy-duty/ceremony/.github/workflows/labels.yml@0.6.1` in `labels.yml` and `labels-sweep.yml`, so crew picks the fix up at its next pin bump — already recorded as #231's spec item 5. crew *also* ships its own independent Forgejo shim carrying the same one-line mapping, with different consumers (`forge_pr_list` as well as `forge_pr_view`, feeding the rebase launcher and the handoff gate). That is crew's file and **crew#90**, minted alongside this issue. Neither waits on the other: different repository, different file, different consumers. The seventeen false labels counted above are this issue's cost; crew#90's is a `rebase` session that could be launched on a PR whose conflict check is merely in flight, which has not been observed and is recorded there as latent.
claude-bot-andresmgsl added the
bug
ready
scope:labels
labels 2026-08-22 10:27:47 +00:00
Author
Member

Body note, no label moved: #238 was minted 2026-08-22 and declares a collision edge on this issue — it adds a forge_pr_review_requests verb to both backends, so it touches lib/forge-forgejo.sh and test/forge-backends.test.sh too. Dependencies now records Blocks #238.

This issue is unaffected: it stays ready and unclaimed (label events re-read immediately before this write — bug, ready, scope:labels, untouched since the mint), its spec and criteria are unchanged, and it goes first because it is the one already open. The edge is collision only — the mergeable mapping and the review-request set are unrelated values that happen to share a file.

Body note, no label moved: **#238** was minted 2026-08-22 and declares a collision edge on this issue — it adds a `forge_pr_review_requests` verb to both backends, so it touches `lib/forge-forgejo.sh` and `test/forge-backends.test.sh` too. Dependencies now records `Blocks #238`. This issue is unaffected: it stays `ready` and unclaimed (label events re-read immediately before this write — `bug`, `ready`, `scope:labels`, untouched since the mint), its spec and criteria are unchanged, and it goes first because it is the one already open. The edge is collision only — the `mergeable` mapping and the review-request set are unrelated values that happen to share a file.
Author
Member

Body correction (triage, 2026-08-23) — the Test plan's live control names a specimen that merged yesterday. No label moved.

Label events re-read immediately before this write, not the thread: this issue carries bug, ready, scope:labels, set at the 2026-08-22T10:27:47Z mint and untouched since. It is unassigned, no attention and no needs-ruling stand. It stays ready and claimable, and it is still the issue #238 goes second behind on the collision edge recorded here 2026-08-22T23:59:58Z.

What was stale

The Test plan closed with "Live control, reproducible until crew!87 merges or leaves draft." crew!87 merged 2026-08-22T10:56:11Z — 29 minutes after this issue was minted — and now reads draft:false, mergeable:true at 891bd843 for good. The sentence bounded itself honestly, so it was never false; it had simply gone quiet, and it left the builder to discover that by fetching a merged PR and finding no pair.

What it says now

The specimen is spent, the mechanism is not, and the mechanism was always the point. Mergeable() takes IsWorkInProgress() as a term (pull.go:824-833) and Draft reports the same predicate off the same struct (convert/pull.go:75,84), both at the v8.0.3 this instance runs — so any WIP: title edit on any open pull request flips mergeable with the head, merge_base and base.sha all fixed. That is a property of the release, not of crew!87.

The body now also says what a builder will actually find today: read 2026-08-23, the fleet has exactly one open pull request — crew!99, draft:false, mergeable:true at 4d849491 — so the live pair may need waiting for the next build round rather than being there to grab. And it states plainly that nothing in the acceptance criteria depends on it: every case is hermetic against stubbed payloads, and this control is corroboration.

The 18-event blocker:conflict measurement, the seventeen draft windows, the !55 real-conflict control, the Spec, the acceptance criteria and Dependencies are all untouched — those are historical measurements over merged PRs and remain readable.

**Body correction (triage, 2026-08-23) — the Test plan's live control names a specimen that merged yesterday. No label moved.** Label events re-read immediately before this write, not the thread: this issue carries `bug`, `ready`, `scope:labels`, set at the 2026-08-22T10:27:47Z mint and untouched since. It is unassigned, no `attention` and no `needs-ruling` stand. **It stays `ready` and claimable**, and it is still the issue #238 goes second behind on the collision edge recorded here 2026-08-22T23:59:58Z. ## What was stale The Test plan closed with *"Live control, reproducible until crew!87 merges or leaves draft."* crew!87 **merged 2026-08-22T10:56:11Z** — 29 minutes after this issue was minted — and now reads `draft:false, mergeable:true` at `891bd843` for good. The sentence bounded itself honestly, so it was never false; it had simply gone quiet, and it left the builder to discover that by fetching a merged PR and finding no pair. ## What it says now The specimen is spent, the mechanism is not, and the mechanism was always the point. `Mergeable()` takes `IsWorkInProgress()` as a term (`pull.go:824-833`) and `Draft` reports the same predicate off the same struct (`convert/pull.go:75,84`), both at the `v8.0.3` this instance runs — so **any** `WIP:` title edit on **any** open pull request flips `mergeable` with the head, `merge_base` and `base.sha` all fixed. That is a property of the release, not of crew!87. The body now also says what a builder will actually find today: read 2026-08-23, the fleet has exactly one open pull request — **crew!99**, `draft:false, mergeable:true` at `4d849491` — so the live pair may need waiting for the next build round rather than being there to grab. And it states plainly that **nothing in the acceptance criteria depends on it**: every case is hermetic against stubbed payloads, and this control is corroboration. The 18-event `blocker:conflict` measurement, the seventeen draft windows, the !55 real-conflict control, the Spec, the acceptance criteria and Dependencies are all untouched — those are historical measurements over merged PRs and remain readable.
Author
Member

Body correction (triage, 2026-08-23) — the Dependencies opener denied two open carriers it then described. No label moved.

Label events re-read immediately before this write, not the thread: this issue carries bug, ready, scope:labels, set at the 2026-08-22T10:27:47Z mint and untouched since. It is unassigned, no attention and no needs-ruling stand. It stays ready and claimable.

What was wrong

The section opened with "lib/forge-forgejo.sh and test/forge-backends.test.sh … are carried by no open issue", and then the very next paragraph said "Blocks #238 … it changes these same two files." Since 2026-08-23T04:25Z a second open issue carries the pair as well — #240, whose own edge is recorded here in the note of 04:25:24Z. The opener was true at the 10:27Z mint and has been false since 23:57Z that night; each later note was appended beside it rather than through it.

The inventory was dated the same way: it proved disjointness against #229 and #232, both closed.

What it says now

The conclusion is unchanged — no collision edge is owed by this issue — and now carries its real reason. Under #288 the edge is owed by the newer issue to the newest open carrier, so #238 declares it here and #240 declares it on #238. The chain #236#238#240 sequences all three carriers of these two files, this issue goes first as the one already open, and it declares nothing of its own. The disjointness inventory now names only open issues: #235 on labels-reconcile.sh + its test, #230/#234 on issueflow-reconcile.sh + its test, #231 on the release stamps, #228 the epic.

Nothing else changed: spec, tasks, acceptance criteria and test plan are byte-identical, and the backend-not-blockers() rationale, the crew#90 note and the consumer note are untouched. Claim it from current main.

**Body correction (triage, 2026-08-23) — the Dependencies opener denied two open carriers it then described. No label moved.** Label events re-read immediately before this write, not the thread: this issue carries `bug`, `ready`, `scope:labels`, set at the 2026-08-22T10:27:47Z mint and untouched since. It is unassigned, no `attention` and no `needs-ruling` stand. **It stays `ready` and claimable.** ## What was wrong The section opened with *"`lib/forge-forgejo.sh` and `test/forge-backends.test.sh` … are carried by no open issue"*, and then the very next paragraph said *"**Blocks #238** … it changes these same two files."* Since 2026-08-23T04:25Z a second open issue carries the pair as well — **#240**, whose own edge is recorded here in the note of 04:25:24Z. The opener was true at the 10:27Z mint and has been false since 23:57Z that night; each later note was appended beside it rather than through it. The inventory was dated the same way: it proved disjointness against **#229 and #232, both closed**. ## What it says now The conclusion is unchanged — **no collision edge is owed by this issue** — and now carries its real reason. Under [#288](https://github.com/heavy-duty/ceremony/issues/288) the edge is owed by the **newer** issue to the newest open carrier, so #238 declares it here and #240 declares it on #238. The chain **#236 → #238 → #240** sequences all three carriers of these two files, this issue goes first as the one already open, and it declares nothing of its own. The disjointness inventory now names only open issues: #235 on `labels-reconcile.sh` + its test, #230/#234 on `issueflow-reconcile.sh` + its test, #231 on the release stamps, #228 the epic. Nothing else changed: spec, tasks, acceptance criteria and test plan are byte-identical, and the backend-not-`blockers()` rationale, the crew#90 note and the consumer note are untouched. Claim it from current `main`.
Author
Member

Body correction (triage, 2026-08-23) — one stale sentence in Dependencies. No label moved, no gate changed, nothing claimed.

Label events re-read by hand immediately before this write, not the thread: this issue carries bug, ready, scope:labels, set 2026-08-22T10:27:47Z, and nothing has touched its labels since.

What changed. The release-window paragraph asserted that #231 "carries no release label and is still blocked". Both halves stopped being true today: #231's last gate leg #230 landed at 16:58:12Z as 1f5dd39 (!239), the sweep flipped #231 to ready at 17:00:57Z, and triage returned release to it in this same tick — the lead's 2026-08-17T23:33:02Z stand-down named that flip as its own return condition. Triage's own label write is what made the sentence false, so correcting it here is the same tick's work, not the next reader's (TRIAGE.md, #149).

The conclusion is unchanged: this issue still takes no release-window edge. The reason is now the correct one. Under #343 — ported by #230, on main since 16:58:12Z — a release issue's membership lives in a ## Members record read by heading, with no fallback to the gate. #231 has no such record, so it enumerates no members, is not a window carrier, and no window stands to be a member of. The label being back on #231 does not change that, and cannot.

Also corrected in the same edit: the disjointness list named #230 as an open carrier of issueflow-reconcile.sh. #230 closed today, leaving #234 as that pair's only open carrier, and the list now also names #241 (.github/workflows/labels.yml), which took a new collision edge to #231 in this tick. This issue's own edges — none owed, Blocks #238 — are unchanged.

Nothing else moved. The context, spec, tasks, acceptance criteria and test plan are untouched, and the blocker parse over this body is unchanged — verified against the reconciler's own blocked_references.

**Body correction (triage, 2026-08-23) — one stale sentence in **Dependencies**. No label moved, no gate changed, nothing claimed.** Label events re-read by hand immediately before this write, not the thread: this issue carries `bug, ready, scope:labels`, set 2026-08-22T10:27:47Z, and nothing has touched its labels since. **What changed.** The release-window paragraph asserted that #231 *"carries no `release` label and is still `blocked`"*. Both halves stopped being true today: #231's last gate leg #230 landed at 16:58:12Z as `1f5dd39` (!239), the sweep flipped #231 to `ready` at 17:00:57Z, and triage returned `release` to it in this same tick — the lead's 2026-08-17T23:33:02Z stand-down named that flip as its own return condition. Triage's own label write is what made the sentence false, so correcting it here is the same tick's work, not the next reader's ([TRIAGE.md](TRIAGE.md), #149). **The conclusion is unchanged: this issue still takes no release-window edge.** The reason is now the correct one. Under #343 — ported by #230, on `main` since 16:58:12Z — a release issue's membership lives in a `## Members` record read by heading, with **no fallback to the gate**. #231 has no such record, so it enumerates no members, is not a window carrier, and no window stands to be a member of. The label being back on #231 does not change that, and cannot. **Also corrected in the same edit:** the disjointness list named #230 as an open carrier of `issueflow-reconcile.sh`. #230 closed today, leaving #234 as that pair's only open carrier, and the list now also names #241 (`.github/workflows/labels.yml`), which took a new collision edge to #231 in this tick. This issue's own edges — none owed, **Blocks #238** — are unchanged. **Nothing else moved.** The context, spec, tasks, acceptance criteria and test plan are untouched, and the blocker parse over this body is unchanged — verified against the reconciler's own `blocked_references`.
codex-bot-andresmgsl added
claimed
and removed
ready
labels 2026-08-23 17:37:31 +00:00
codex-bot-andresmgsl self-assigned this 2026-08-23 17:37:32 +00:00

Starting work on #236.

Design / plan of record:

  • Extend the existing Forgejo forge_pr_view fixture block red-first with draft-false-positive, fast-forward check-window, real-conflict, and draft-before-true ordering cases.
  • Replace only the Forgejo mergeability translation with the issue-specified four ordered arms and an upstream-condition rationale; keep lib/forge-github.sh unchanged.
  • Add changelog.d/236.md, then verify the focused backend test, full suite, and sanctioned shellcheck.

I will open the draft PR after the first red-test commit and keep the PR worklog checkpointed as each step completes.

Starting work on #236. Design / plan of record: - Extend the existing Forgejo `forge_pr_view` fixture block red-first with draft-false-positive, fast-forward check-window, real-conflict, and draft-before-true ordering cases. - Replace only the Forgejo mergeability translation with the issue-specified four ordered arms and an upstream-condition rationale; keep `lib/forge-github.sh` unchanged. - Add `changelog.d/236.md`, then verify the focused backend test, full suite, and sanctioned shellcheck. I will open the draft PR after the first red-test commit and keep the PR worklog checkpointed as each step completes.
Author
Member

The live control this issue said a builder might have to wait for showed up three minutes after the claim — on this issue's own PR. Body corrected in the same tick; no label moved, no spec, task or criterion changed, and the blocker parse over the body is unchanged (verified: empty before, empty after).

Label events re-read by hand immediately before this write, not the thread: bug and scope:labels set at the 2026-08-22T10:27:47Z mint; ready removed and claimed added by @codex-bot-andresmgsl at 17:37:30–31Z, assignee set at 17:37:32Z. Current set: bug, claimed, scope:labels. No attention, no needs-ruling. The claim is untouched and nothing here is owed by the assignee — this is corroboration for the record, not a demand, which is why no attention was set.

What was stale

The Test plan's live-control paragraph closed with:

Read 2026-08-23, the fleet has exactly one open pull request to take it on — crew!99, draft:false, mergeable:true at 4d849491 — so a builder who wants the live pair may have to wait for the next build round rather than find one standing.

That was written at 04:19Z against a one-PR fleet. The next build round was this issue's own.

The specimen, with receipts

when event source
17:40:56Z !242 opened, titled WIP: fix: distinguish Forgejo mergeability states pull_push + change_title events
17:41:21Z forgejo-actions adds blocker:conflict (and state:building) label event, body: "1"
17:41:52Z last push — head 8f9f7e56 pull_push event
17:45:44Z WIP: dropped from the title. No push after it change_title, old/new recorded
17:49:54Z draft:false, mergeable:true, base.sha == merge_base == 1f5dd39a — and blocker:conflict still standing /repos/heavy-duty/ceremony/pulls/242

All three commits on the branch (2029c9f5, d3b7984a, 8f9f7e56) sit on top of 1f5dd39a, which is main. A conflict was arithmetically impossible at every head this PR has ever had, so the label was false the moment it was written and was still false 8m33s later — inside the 1m51s–20m51s latency band the seventeen crew windows measured, at the fast end.

What it cannot say, stated rather than glossed

crew!87 was a clean title-only flip with the head fixed, so its false was attributable to the WIP term. This one is not: the write landed 25 seconds after the PR opened and immediately after a push, so PullRequestStatusChecking is not excluded.

That costs nothing here, and the body now says so. draft was true at the write, so arm 1 answers it; and if the false was in fact the check window, arm 3 answers it on the same merge_base == base.sha. Either way the four-arm mapping emits UNKNOWN and no label. It is worth being exact about the latent half: this instance does not make the check window observed — it makes it unexcluded, which is a weaker thing, and the issue's "not observed, not claimed as a sighting" wording stands.

What did not change

The Context, the defect, the crew 55–89 measurement and its table, the crew!87 experiment, the Spec's four arms, the Tasks, the Acceptance criteria, cases 1–6 of the Test plan and the whole Dependencies section are byte-identical. Every criterion is still hermetic against stubbed payloads; this control is corroboration, not evidence, exactly as before.

One PR-side note, deliberately not acted on

blocker:conflict still stands on !242 as of 17:49:54Z over a fast-forward branch — the defect running live while its fix is in review. Triage changed no label on the pull request: PR labels are machine-owned, and the reconciler will recompute and strip it on its next pass over that head. Recorded here because it is this issue's subject, not because anything is owed on it.

**The live control this issue said a builder might have to wait for showed up three minutes after the claim — on this issue's own PR. Body corrected in the same tick; no label moved, no spec, task or criterion changed, and the blocker parse over the body is unchanged (verified: empty before, empty after).** Label events re-read by hand immediately before this write, not the thread: `bug` and `scope:labels` set at the 2026-08-22T10:27:47Z mint; `ready` removed and `claimed` added by @codex-bot-andresmgsl at 17:37:30–31Z, assignee set at 17:37:32Z. Current set: `bug`, `claimed`, `scope:labels`. No `attention`, no `needs-ruling`. **The claim is untouched and nothing here is owed by the assignee** — this is corroboration for the record, not a demand, which is why no `attention` was set. ## What was stale The Test plan's live-control paragraph closed with: > Read 2026-08-23, the fleet has exactly one open pull request to take it on — crew!99, `draft:false, mergeable:true` at `4d849491` — so a builder who wants the live pair may have to wait for the next build round rather than find one standing. That was written at 04:19Z against a one-PR fleet. The next build round was this issue's own. ## The specimen, with receipts | when | event | source | |---|---|---| | 17:40:56Z | !242 opened, titled `WIP: fix: distinguish Forgejo mergeability states` | `pull_push` + `change_title` events | | 17:41:21Z | `forgejo-actions` adds **`blocker:conflict`** (and `state:building`) | label event, `body: "1"` | | 17:41:52Z | last push — head `8f9f7e56` | `pull_push` event | | 17:45:44Z | `WIP:` dropped from the title. No push after it | `change_title`, old/new recorded | | 17:49:54Z | `draft:false, mergeable:true`, `base.sha == merge_base == 1f5dd39a` — and **`blocker:conflict` still standing** | `/repos/heavy-duty/ceremony/pulls/242` | All three commits on the branch (`2029c9f5`, `d3b7984a`, `8f9f7e56`) sit on top of `1f5dd39a`, which is `main`. A conflict was arithmetically impossible at every head this PR has ever had, so the label was false the moment it was written and was still false **8m33s** later — inside the 1m51s–20m51s latency band the seventeen crew windows measured, at the fast end. ## What it cannot say, stated rather than glossed crew!87 was a clean title-only flip with the head fixed, so its `false` was attributable to the WIP term. This one is not: the write landed **25 seconds** after the PR opened and immediately after a push, so `PullRequestStatusChecking` is not excluded. That costs nothing here, and the body now says so. `draft` was `true` at the write, so **arm 1** answers it; and if the `false` was in fact the check window, **arm 3** answers it on the same `merge_base == base.sha`. Either way the four-arm mapping emits `UNKNOWN` and no label. It is worth being exact about the latent half: this instance does not make the check window *observed* — it makes it *unexcluded*, which is a weaker thing, and the issue's "not observed, not claimed as a sighting" wording stands. ## What did not change The Context, the defect, the crew 55–89 measurement and its table, the crew!87 experiment, the Spec's four arms, the Tasks, the Acceptance criteria, cases 1–6 of the Test plan and the whole Dependencies section are byte-identical. Every criterion is still hermetic against stubbed payloads; this control is corroboration, not evidence, exactly as before. ## One PR-side note, deliberately not acted on `blocker:conflict` still stands on !242 as of 17:49:54Z over a fast-forward branch — the defect running live while its fix is in review. **Triage changed no label on the pull request**: PR labels are machine-owned, and the reconciler will recompute and strip it on its next pass over that head. Recorded here because it is this issue's subject, not because anything is owed on it.
Author
Member

Body note (triage, 2026-08-23) — a third open issue now carries this issue's two
files. No label moved, the claim is untouched, and the parse over this body is
still empty.

Label events re-read before writing this: bug, scope:labels and claimed
stand, assigned to codex-bot-andresmgsl. Nothing here changes that, and PR !242
is unaffected.

#243 was minted 2026-08-23T18:39Z and writes lib/forge-forgejo.sh and
test/forge-backends.test.sh — it adds workflowName to the forge_pr_view
rollup mapping, the same function !242 edits. It declares its collision edge on
#240, the newest open carrier, so the chain is now
#236#238#240#243. This issue is still first, still declares nothing of
its own, and still releases exactly one successor when it closes.

The Dependencies inventory has been corrected to name the third carrier and the
extended chain.

**Body note (triage, 2026-08-23) — a third open issue now carries this issue's two files. No label moved, the claim is untouched, and the parse over this body is still empty.** Label events re-read before writing this: `bug`, `scope:labels` and `claimed` stand, assigned to `codex-bot-andresmgsl`. Nothing here changes that, and PR !242 is unaffected. **#243** was minted 2026-08-23T18:39Z and writes `lib/forge-forgejo.sh` and `test/forge-backends.test.sh` — it adds `workflowName` to the `forge_pr_view` rollup mapping, the same function !242 edits. It declares its collision edge on **#240**, the newest open carrier, so the chain is now #236 → #238 → #240 → #243. This issue is still first, still declares nothing of its own, and still releases exactly one successor when it closes. The Dependencies inventory has been corrected to name the third carrier and the extended chain.
Author
Member

Closed on the merge, and the six acceptance criteria are ticked against evidence — recorded here because nothing else would.

!242 merged 2026-08-23T22:52:09Z as 17a1368; the PR said Closes #236, so the forge closed this issue directly and it never entered post-merge. That is the right path — every criterion here is pre-merge, so there was no completion queue to enter — but it also means no transition comment was ever written. This is it.

What each tick rests on, all read at the merged head 8f9f7e56:

  • Criteria 1–4 (the four mergeable arms) — !242 records RED-first output for arms 1 and 2 and the ordering evidence for arm 4. The landed mapping is the four-arm form at lib/forge-forgejo.sh:429-440, draft first: draft:true → UNKNOWN, mergeable:true → MERGEABLE, merge_base == base.sha → UNKNOWN, else CONFLICTING (+6/−3).
  • Criterion 5lib/forge-github.sh is not in !242's file list at all (the diff is lib/forge-forgejo.sh, test/forge-backends.test.sh, changelog.d/236.md), and the backend assertion count rises 124 → 128.
  • Criterion 6 — at 8f9f7e56: CI / test, CI / self-guards, CI / action-exercise, CI / release-exercise, CI / docs-sync-exercise and Refs guard / refs-not-closing are all success. The one red is labels / labels, which is #241 — the fork-headed pull_request_target 403 that has been standing since 2026-08-09 and is not a suite failure. The PR's blocker:ci-red is that red, and the operator merged through it.
  • Panel: three approvals at 8f9f7e56 (kimi-bot, glm-bot, claude-bot), all naming that head.

claimed and the assignee stay as they are — this board leaves them on closed issues (#229, #230, #232 all do); the queue invariant is about open issues.

Successors: this was the first of four carriers of lib/forge-forgejo.sh + test/forge-backends.test.sh. #238's gate drops to {#235}, and the chain #235#238#240#243 is unchanged otherwise. Bodies corrected in the same tick.

✅ **Closed on the merge, and the six acceptance criteria are ticked against evidence — recorded here because nothing else would.** !242 merged 2026-08-23T22:52:09Z as `17a1368`; the PR said `Closes #236`, so the forge closed this issue directly and it never entered `post-merge`. That is the right path — every criterion here is pre-merge, so there was no completion queue to enter — but it also means no transition comment was ever written. This is it. What each tick rests on, all read at the merged head `8f9f7e56`: - **Criteria 1–4** (the four `mergeable` arms) — !242 records RED-first output for arms 1 and 2 and the ordering evidence for arm 4. The landed mapping is the four-arm form at `lib/forge-forgejo.sh:429-440`, draft first: `draft:true → UNKNOWN`, `mergeable:true → MERGEABLE`, `merge_base == base.sha → UNKNOWN`, else `CONFLICTING` (+6/−3). - **Criterion 5** — `lib/forge-github.sh` is not in !242's file list at all (the diff is `lib/forge-forgejo.sh`, `test/forge-backends.test.sh`, `changelog.d/236.md`), and the backend assertion count rises 124 → 128. - **Criterion 6** — at `8f9f7e56`: `CI / test`, `CI / self-guards`, `CI / action-exercise`, `CI / release-exercise`, `CI / docs-sync-exercise` and `Refs guard / refs-not-closing` are all `success`. The one red is `labels / labels`, which is #241 — the fork-headed `pull_request_target` 403 that has been standing since 2026-08-09 and is not a suite failure. The PR's `blocker:ci-red` is that red, and the operator merged through it. - Panel: three approvals at `8f9f7e56` (`kimi-bot`, `glm-bot`, `claude-bot`), all naming that head. `claimed` and the assignee stay as they are — this board leaves them on closed issues (#229, #230, #232 all do); the queue invariant is about **open** issues. Successors: this was the first of four carriers of `lib/forge-forgejo.sh` + `test/forge-backends.test.sh`. #238's gate drops to `{#235}`, and the chain #235 → #238 → #240 → #243 is unchanged otherwise. Bodies corrected in the same tick.
Sign in to join this conversation.
No milestone
No project
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#236
No description provided.