fix(source): resolve PR merge state on first load, not on refresh - #565
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Sound root-cause fix: bounds a merge-field re-read to defeat lazy mergeability, and reuses #443's chip↔payload coherence protocol rather than bolting on a parallel path. Suggestions
[DESIGN-REVIEWED] 79c2de1 |
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsBoth line-level reviewers (Claude and GPT 5.6) reported no findings at all, and the design reviewer passed with a single suggestion — a note that the Arbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Suggested follow-ups (open as issues — non-blocking)
[ARBITER-REVIEWED] 79c2de1 False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
Opus 5 Review — ✅ no blocking findingsReviewed No findings. Verdict recorded via the action's structured output for commit False positive or not applicable? A repository writer can comment: |
Disposition for round 1 (reviewed SHA
|
6502cce to
998247a
Compare
Disposition for round 2 (reviewed SHA
|
998247a to
c6bbc35
Compare
c6bbc35 to
94ec568
Compare
Disposition for round 3 (reviewed SHA
|
94ec568 to
6e5ec7d
Compare
Disposition for round 4 (reviewed SHA
|
Both providers compute mergeability lazily. The first read of a pull request they have not evaluated recently answers "not known yet" (GitHub UNKNOWN, GitLab checking/unchecked) and is itself what starts the computation, so a single read reports a conflicting PR as having no merge blocker at all. The panel read once, so its conflict banner only appeared after the user hit refresh -- the second read that got the real answer. Full fetches now re-read the merge fields alone (at most twice, 0.8s apart) until they settle, dispatched inside the existing secondary fanout so the wait overlaps calls the request was already making. An omitted field is never re-read, and a failed or still-unsettled re-read degrades to unknown rather than failing the panel. The short-TTL chip-status cache also carries the settled merge pair -- free on the GitHub call, already present in GitLab's payload -- and the panel folds a fresher poll answer into its pinned payload. That covers the second way a conflict stayed hidden: one that starts after the panel opened, which the staleTime-Infinity query would never notice. Unsettled values are never cached, so "still computing" cannot overwrite a real answer.
6e5ec7d to
79c2de1
Compare
…rodotdev#565) Both providers compute mergeability lazily. The first read of a pull request they have not evaluated recently answers "not known yet" (GitHub UNKNOWN, GitLab checking/unchecked) and is itself what starts the computation, so a single read reports a conflicting PR as having no merge blocker at all. The panel read once, so its conflict banner only appeared after the user hit refresh -- the second read that got the real answer. Full fetches now re-read the merge fields alone (at most twice, 0.8s apart) until they settle, dispatched inside the existing secondary fanout so the wait overlaps calls the request was already making. An omitted field is never re-read, and a failed or still-unsettled re-read degrades to unknown rather than failing the panel. The short-TTL chip-status cache also carries the settled merge pair -- free on the GitHub call, already present in GitLab's payload -- and the panel folds a fresher poll answer into its pinned payload. That covers the second way a conflict stayed hidden: one that starts after the panel opened, which the staleTime-Infinity query would never notice. Unsettled values are never cached, so "still computing" cannot overwrite a real answer. Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
Problem
In the chat SidePanel's Changes view, a pull request with merge conflicts showed no merge-blocker banner at all when the panel first loaded. The conflict only appeared after the user clicked the panel's refresh button.
Why it matters
The banner is the panel's only signal that a branch cannot be merged, and it carries the "Add to chat" handoff that asks the agent to resolve the conflict. Silently showing nothing reads as "nothing blocks this merge" — the user learns the branch is conflicting somewhere else (or not at all) and the handoff is never offered. Anyone babysitting a PR from the panel was getting a clean-looking header on a branch that could not merge.
Fix (symptom → root cause → change)
Symptom: conflicts appear only after a manual refresh.
Root cause: both providers compute mergeability lazily. The first read of a pull request they have not evaluated recently answers "not known yet" — GitHub
UNKNOWN, GitLabchecking/unchecked— and that read is itself what starts the computation. A single read therefore normalizes tomergeable: 'unknown', whichpullRequestMergeBlockercorrectly treats as "no known blocker", so no banner renders. The user's refresh click was simply the second read, which got the real answer.Reproduced directly against this repository:
Change, in two parts, because there were two distinct ways a conflict stayed hidden:
First load (
_github_settled_merge_state/_gitlab_settled_merge_state) — each full fetch now re-reads only the merge fields until they settle: at most_MERGE_STATE_REREADS(2) attempts spaced_MERGE_STATE_REREAD_DELAY_SECS(0.8s) apart. The re-read coroutine is dispatched inside the existing secondary fanout, so its wait overlaps the files/discussions/pipelines calls the request was already making rather than adding to them. A value that is empty rather than unknown is never re-read — the provider omitted the field, so re-reading cannot settle it. An unsettled, failed, or malformed re-read degrades tounknownrather than raising: an unknown merge state costs one banner, never the panel.Conflicts that begin after the panel opened — the panel's payload is pinned (
staleTime: Infinity) and only refetches on a manual refresh, so a branch that started conflicting while the panel was open would never be noticed. This rides the chip↔payload coherence protocol fix(chat): keep pull-request state in sync across sidebar and detail panel #443 already built rather than adding a parallel one: the merge pair is recorded in the short-TTL chip entry, so it participates in the existing change detection — a chip refresh that sees the pair move drops the full payload for that URL and pushes asource_statusdelta, and every owner window invalidates its detail query and re-reads. The banner therefore always comes from an authoritative provider read, and it converges across windows.Three lines of integration make that work: the pair is recorded in the chip entry (each field independently, only once real, never as
unknown);status_from_full_payloadprojects it too; andparseStatusDelta/applyStatusDeltaaccept it so a merge-only change is not discarded as a field-less delta.PullRequestPanel.tsxis unchanged — there is no frontend component change in this PR.The projection in
status_from_full_payloadis load-bearing, not tidiness: without it every full fetch would rewrite the chip entry without the fields the chip read records, so the next chip refresh would judge that a change, drop the full payload, and the write-through would strip them again — the repeating chip↔full transition fix(chat): keep pull-request state in sync across sidebar and detail panel #443's flap damper exists to contain, spun by a projection gap.Tests
Backend (
test/test_source_providers.py, 17 new):..._rereads_merge_state_until_the_provider_settles_it(GitHub + GitLab) — first readUNKNOWN/checking, re-read settles to conflicting; also asserts the GitHub re-read requests the merge fields alone, not another full fanout...._does_not_reread_settled_merge_state— a settled first read issues zero re-reads...._degrades_to_unknown_when_reread_cannot_settle(GitHub parametrized over unsettled / provider-error / invalid-payload, plus GitLab) — payload keepsunknown, the rest of the payload survives, and the re-read budget is respected (failure stops immediately; unsettled uses the full bound)...._skips_reread_when_provider_omits_merge_fields— an omitted field is not "still computing"._merge_state_settledparametrized over all seven pair shapes, plus..._treats_a_detail_only_answer_as_settled— locks pair-based settledness so GitLab'sneed_rebaseis not re-read and discarded...._check_status_carries_settled_merge_state(GitHub + GitLab),..._carries_a_detail_only_answer, and..._omits_unsettled_merge_state(parametrized overUNKNOWN/ absent) — locks the chip-cache contract: each field recorded independently, only once real, never asunknown.status_from_full_payloadprojects the pair, and both projections agree on a GitLab detail-only answer — the two tests that pin the flap-loop gap described above.Frontend (
website/src/test/pullRequestStatusDelta.test.ts, 5 new):parseStatusDeltaand lands viaapplyStatusDelta(without this it is discarded as field-less and the banner waits for a refresh);mergeStateStatuswith no settledmergeable) survives;state/ci;All 22 were revert-probed: with each fix semantically reverted, the corresponding tests fail rather than passing vacuously.
Manual verification
Root cause confirmed against live GitHub, as shown above — the lazy-computation behavior is what the fix targets, and it is not reproducible from unit fixtures alone. The rendered behavior is covered by the frontend test asserting the banner appears from a poll with no refetch, so no additional manual pass was needed.
Screenshots
N/A — no new or changed UI surface. This makes an existing component (the merge-blocker banner, added in an earlier PR) appear when it already should have; its markup, copy, and styling are untouched.
Local gates
17969 pytest·4682 vitest·isort/flake8/mypy(481 files) /tsc -b/eslint/scrub-lintall clean. Three pre-existingtest/test_dashboard_origin.py::TestParseDashboardUrlMalformedfailures reproduce on a cleanmaincheckout at54fac64aand are unrelated to this change.Review history
Two independent read-only pre-submit reviewers cleared the diff with no Critical/High findings; their two Low items (spec
to_dict()clause and the TypeScript chip types, both about owner-gated sidebar chips spreading the whole cached entry) were fixed before the first push.Round 1 of GPT 5.6 raised two legitimate findings, both since resolved — see the disposition comments for the full reasoning:
dataUpdatedAton two queries, i.e. response times rather than data freshness, so a chip entry served before its background refresh could overwrite a fresher payload. Round 2 replaced the copy with a forced re-read on disagreement; the rebase onto fix(chat): keep pull-request state in sync across sidebar and detail panel #443 then removed the client-side comparison entirely, since fix(chat): keep pull-request state in sync across sidebar and detail panel #443's server-driven chip→payload invalidation already does this correctly and across windows.PullRequestPanel.tsxends up unchanged.need_rebase/branch-protection states settle in the detail field withmergeable == "unknown", so gating onmergeablealone dropped them from the chip cache and left exactly those banners invisible. Resolved by judging settledness on the pair and recording the two fields independently.Arbiter passed on round 1 and classed both as deferrable follow-ups; they were fixed rather than deferred since both were in newly added code.
The rebase onto #443 also surfaced a latent flap-loop hazard (a chip↔full projection gap) that is fixed and pinned by tests here — see the Fix section.