Skip to content

fix(github): stop a stale review request or an "Update branch" merge from clearing a rejection - #909

Merged
patzick merged 4 commits into
mainfrom
cez/7474348a
Aug 20, 2026
Merged

patzick merged 4 commits into
mainfrom
cez/7474348a

Conversation

@patzick

@patzick patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

A rejected PR could show Waiting for review (blue, the reviewer's move) instead of Changes requested (red, the author's move), with nobody having addressed anything. Reported from the cockpit with the chip live on screen.

Two independent causes, one commit each.

1. A review request older than the review is not an answer to it

derivePrReferenceStatus read any pending reviewRequests.totalCount > 0 as the author clicking re-request. But that field says only that someone is on the hook, never since when.

Live data for the reported PR:

signal value
reviewDecision CHANGES_REQUESTED
review submitted 2026-08-19T11:00:27Z
head commit 2026-08-18T10:23:40Zolder
pending request 1, asked 2026-08-18T10:28:43Zalso older

Three reviewers were asked at once, one rejected, the other two never looked — so the count sits at 1 with nothing having happened since. Read as a re-request, that stale entry painted "Waiting for review" over a live rejection.

A standing request now has to postdate the review. Its date comes from timelineItems(last: 1, itemTypes: [REVIEW_REQUESTED_EVENT]).

2. "Update branch" is not the author answering a review

The other signal — a commit newer than the review — was cleared by GitHub's Update branch button, which writes Merge branch 'main' into <branch> dated now: newer than any review, addressing none of it. The click people make reflexively on a stale PR wiped the rejection off the chip.

A head commit with parents.totalCount >= 2 no longer counts as a push. Shape confirmed live on a real PR whose head is exactly such a merge.

Deliberately unchanged

  • A real push still hands the ball back. That behaviour was confirmed as correct by the reporter, and it is why reviewDecision staying CHANGES_REQUESTED forever doesn't pin the blame on the author.
  • An empty reviews with a pending request still counts — the earlier feat(workspace): register the boot folder only while the registry is empty #774 finding, the case with no other tell.
  • Absence stays conservative. An undated request against a dated review, or a missing parent count, never dismisses a review on a guess. The one exception is documented: an absent parent count reads as an ordinary commit, because the push rule is the common path and must not switch off on a field GitHub declined to send.

Known gap

Update with rebase still clears a rejection: it rewrites the committer date on every commit and is indistinguishable from real work. The only fix is dropping the pushed-since rule entirely, which would leave a sticky red chip for authors who push a fix without clicking re-request. That trade was offered and not taken; the spec records why.

Cost

No extra request. Both new fields ride the same aliased node as the rest — parents(first: 0) fetches the count and no parent.

Verification

  • 157/157 in packages/cezar/src/server/forge/github.test.ts. Each fix's new tests were checked to fail against the pre-fix logic (4 and 3 respectively), so they can't pass vacuously.
  • The combination matrix grew from 64 to 256 rows (two new dimensions) with two added invariants: no combination lets a stale request dismiss a review, and none lets a merge stand in for the author.
  • Both GraphQL query shapes validated against the live API before being written down.
  • npm run typecheck, npm test, npm run test:unit all green apart from 8 failures confirmed pre-existing on a clean tree (health-forge, git-worktree, git, git-changes, automations-*, projects-api, route-parity — all environment/flaky, none in forge/).
  • .ai/specs/2026-08-11-reference-status-chips.md updated: it is the design record this code cites.

…to it

`derivePrReferenceStatus` read any pending `reviewRequests.totalCount > 0`
as the author clicking re-request, and so demoted a live "changes requested"
to "waiting for review".

That is only true of a request made AFTER the review. On a PR where several
reviewers were asked at once and one of them rejected it, the others stay
listed indefinitely with nothing having happened since — observed on
a real PR: three reviewers requested at 2026-08-18T10:28,
changes requested 2026-08-19T11:00, head commit older than both, one request
still pending. The chip said "Waiting for review · no one has reviewed it
yet" over a rejection.

The standing request now has to postdate the review to count. Its date comes
from `timelineItems(last: 1, itemTypes: [REVIEW_REQUESTED_EVENT])`, riding
the same aliased node at no extra request. Two behaviours are deliberately
kept: an empty `reviews` with a pending request still hands the ball back
(the case with no other tell), and a commit newer than the review still does
too — the reporter called that one correct. An undated request against a
dated review is conservative: the review stands.
A merge commit at the head no longer counts as the push that clears a
rejection. GitHub's Update-branch button writes `Merge branch 'main' into
<branch>` dated NOW — newer than any review, addressing none of it — so the
one click people make reflexively on a stale PR flipped the chip from
"Changes requested" to "Waiting for review" with nothing addressed.

`parents.totalCount >= 2` on the head commit is what tells that commit apart
from work. It rides the same aliased node as the rest (`parents(first: 0)`:
the count is the whole question, so no parent is fetched). Shape confirmed
live on a real PR, whose head is exactly such a merge.

An absent count reads as an ordinary commit — the push rule is the common
path and must not switch off on a field GitHub declined to send. Merging the
base AND pushing fixes together, with the merge landing last, reads as
unanswered until the next real commit.

Does not cover "Update with rebase", which rewrites every committer date and
is indistinguishable from real work; the spec records why that trade was not
taken.
`main` grew a second axis for reference chips — `mergeable` on the same
batched node, surfaced as `Mergeability`/`mergeabilityOf` — and it landed on
the three spots this branch was already editing.

Resolved by keeping both sides in each:

- the `refStatusQuery` doc block: this branch's paragraph on `parents` and
  the `ReviewRequestedEvent`, followed by main's on `mergeable` riding the
  same node;
- the query string itself: `mergeable` plus `parents(first: 0)` and the
  `timelineItems` review-request event;
- the helpers: main's `Mergeability`/`mergeabilityOf` kept whole, and this
  branch's `isAfter` in place of `pushedSince`, which it replaced and which
  now has no callers.

The schema and the `derivePrReferenceStatus` call site auto-merged with both
sides' fields intact — verified by reading, not assumed.

The combined query was re-validated against the live GraphQL API on
a real PR: `mergeable`, `parents.totalCount` and the timeline
event all come back together in one request.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

📦 npm preview published — 0.10.0-pr909.1328

Try this PR build (exact pinned version — copy-paste as-is):

npx cezar-cli@0.10.0-pr909.1328                                # cockpit at http://localhost:4321
npx cezar-cli@0.10.0-pr909.1328 run "…"                        # headless run
npx cezar-cli@0.10.0-pr909.1328 server-deploy --platform <id>  # roll a server to this exact build

Also tagged: npm install -g cezar-cli@pr-909 (moving tag for this PR).
Packages: cezar-cli@0.10.0-pr909.1328@open-mercato/cezar@0.10.0-pr909.1328@open-mercato/cezar-api-client@0.10.0-pr909.1328 (provenance attested).

@patzick patzick added the in-progress Cezar agent is actively working this issue label Aug 20, 2026
@patzick patzick self-assigned this Aug 20, 2026
@patzick

patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

🤖 om-auto-review-pr claimed this PR for review at 2026-08-20T13:09:40Z. Lock held via the in-progress label; it will be released when the review is submitted.

@patzick patzick left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review

PR #909fix(github): stop a stale review request or an "Update branch" merge from clearing a rejection
First review (no prior review on this PR). Scope: main...HEAD — 3 files, +304 −45.

🚫 Verdict posted as a comment, not a formal review. This PR is authored by the same account running the review, and GitHub refuses a formal verdict on your own pull request (Can not request changes on your own pull request). The verdict below is CHANGES REQUESTED and is treated as such by this run — the labels, the handoff and the autofix loop all follow it — but it cannot carry the REQUEST_CHANGES review state, and it does not satisfy this repository's review requirement: #909 still needs an independent reviewer to clear BLOCKED.

⚙️ Engine note. The configured review engine om-code-review is not installed on this machine, so this pass ran the locally available code-review engine instead, at high effort, plus the repository's own validation.commands gate in full. The severity model, verdict rule, and report structure below are om-auto-review-pr's as specified. Flagging the substitution because the skill contract names that engine explicitly.

📋 Summary

The PR narrows derivePrReferenceStatus's "the author already answered the review" test along two axes: a standing review request must now postdate the review to count, and a merge commit at the head no longer counts as a push. Both are well-motivated, both carry real observed data behind them, and both are covered by tests that were demonstrated to fail against the pre-fix logic. The combination matrix grew to 256 rows with two new invariants, which is the right shape for a precedence function that has now been edited five times.

Two defects found, both in the second axis's implementation rather than its intent.

🚧 Blockers

packages/cezar/src/server/forge/github.ts:1349 — a merge landing on top erases an earlier genuine answer, flipping the chip back to red.

pushed reads only commits(last: 1), so any merge at the head forces it false regardless of what came before:

const pushed = (pr.headParentCount ?? 1) < 2 && isAfter(pr.headCommittedAt, pr.changesRequestedAt);

Failure sequence, all four steps ordinary:

  1. Reviewer requests changes.
  2. Author pushes a real fix. Head is a normal commit newer than the review → pushed true → chip is blue review-required. Correct.
  3. The base moves on and the PR goes conflicting.
  4. Author presses Resolve conflicts in this very cockpit — whose prompt is literally Merge head branch and resolve conflicts in PR number N — or GitHub's Update branch. The head is now a 2-parent merge.

headParentCount is 2, so pushed collapses to false, answered goes false, and the chip flips back to red changes-requested, blaming an author who answered two steps ago and has since only fixed conflicts.

This is a regression, not a pre-existing gap: before this PR, step 4's merge was newer than the review and read review-required, which was the right answer. It also lands squarely in the PR's own subject — producing exactly the "wrong person is blamed" outcome the change exists to prevent — and it is reachable through a first-class cockpit button, so it is not a corner case.

The commit message anticipates a neighbouring case ("merging the base AND pushing fixes together … reads as unanswered until the next real commit") and calls it conservative. That reasoning holds when there was never an answer to lose; it does not hold here, where a correct blue chip is actively downgraded to a wrong red one.

Fix. Judge the push by the newest commit that is actual work rather than by the head: when the head is a merge, its first parent is the branch's previous tip — precisely the author's last real commit. parents(first: 1) returns that parent's committedDate alongside the totalCount already being fetched, so the correction costs no extra request.

⚠️ Major

packages/cezar/src/server/forge/github.ts:1503reviewRequestedAt and reviewRequested answer questions about different requests.

reviewRequested is reviewRequests.totalCount > 0 — requests standing now. reviewRequestedAt is the newest ReviewRequestedEvent — a request made at some point, whether or not it still stands. Nothing ties the two to the same reviewer.

So: an old request stands (predating the review, the exact case this PR fixes), and a second request was created after the review and then removed. reviewRequested is true from the old one; reviewRequestedAt is dated from the removed one and postdates the review; reRequested goes true and the live rejection is hidden behind "waiting for review" again.

Rarer than the bug this PR fixes — it needs an added-then-removed request alongside a standing older one — but it is the same class, and it defeats the same fix.

Fix. Correlate the two by reviewer: take the newest ReviewRequestedEvent whose requestedReviewer is still in reviewRequests. Both connections are already on the aliased node, so this stays one request.

✅ Verified and cleared

  • Backward compatibility (BACKWARD_COMPATIBILITY.md:60, the protected GET /api/v1/github/ref-status surface): the status vocabulary is untouched — no value added, renamed, or removed. Only which existing value a given PR receives changes, which is the bug fix. Every new field on derivePrReferenceStatus's parameter object is optional, so existing callers compile unchanged, and the function has exactly one caller. mergeabilityOf / the conflicts axis from #904 survived the merge intact.
  • Query cost: both additions ride the existing aliased node — parents(first: 0) fetches the count and no parent. Verified against the live GraphQL API, including a 100-alias batch (the REFERENCE_STATUS_MAX worst case), which returns in the same time as the pre-change query. No extra round trip.
  • Defensive defaults: an absent parent count reads as an ordinary commit and an absent timeline reads as an undated request — both directions deliberate and both test-covered.
  • Test quality: the new tests were each demonstrated to fail against the pre-fix logic, so none can pass vacuously.
  • Hygiene scan: no secrets, debug statements, any, @ts-ignore, .only/.skip, TODOs, dependency changes, or CI/workflow edits in the diff.

🧪 Validation

Full validation.commands gate, in order, in the PR's own worktree at 0dfa79e7:

# Command Result
1 npm run typecheck ✅ pass
2 npm test ⚠️ 8 files failed — all pre-existing, confirmed failing on a clean tree
3 npm run test:unit ✅ 35 pass, 0 fail
4 npm run build ✅ pass — check:pack ok, 475 files
5 npm run test:package ✅ 15 pass, 0 fail

The step-2 failures are git-worktree, git, git-changes, health-forge, automations-api, automations-gate, projects-api, route-parity — environment/spawn-dependent suites, unrelated to this diff. Every suite this PR touches passes: 224/224.

CI is fully green on 0dfa79e7: Publish npm snapshot ✅, Unit, build, E2E, and package ✅, license/cla ✅. No pending checks, no conflicts (MERGEABLE).

🏁 Verdict

Changes requested — one blocker and one major, per the severity rule (any blocker, or any major without a documented waiver, requests changes).

Both are contained in derivePrReferenceStatus and its query, both have a concrete fix that costs no extra request, and neither touches the PR's design — the two axes it adds are right; the merge-head test is simply reading the wrong commit.

@patzick patzick added changes-requested Reviewer requested changes bug Something isn't working priority-medium Ordinary bug or feature risk-low Isolated, low blast radius needs-qa Requires manual QA before merge labels Aug 20, 2026
@patzick

patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

🏷️ label rationale

label why
changes-requested Pipeline state: the review found one blocker and one major, so the PR is back with the author. Replaces review, which was never applied since the PR was unlabelled at claim time.
bug Category: fixes wrong reference-chip states, no new surface.
priority-medium The bug is user-visible and misattributes whose move a PR is, but it is display-only and has a workaround (open the PR).
risk-low One pure function plus its GraphQL projection, one caller, no protected surface touched, no schema or contract change.
needs-qa qaGate is on and the change is observable in the cockpit, so manual QA is wanted before merge.

in-progress stays until this run releases its claim.

Review of #909 found two defects in the merge-head test this branch added.

**A merge landing on top erased an answer the author had already given.**
`pushed` read only `commits(last: 1)`, so any 2-parent head forced it false
regardless of what came before. Sequence: changes requested; author pushes a
real fix (chip correctly turns blue); base moves on; author presses "Resolve
conflicts" (this cockpit's own button) or "Update branch". The head is now a
merge, the fix underneath it is discarded, and the chip flips BACK to red —
blaming an author who answered two steps ago. That was a regression: before
this branch, the merge was newer than the review and read `review-required`,
which was right.

The push is now judged by the newest commit that is actual WORK: the head,
or, when the head is a merge, its FIRST parent — the branch's previous tip,
which is what the merge sat on top of. A plain "Update branch" on a PR that
was never fixed still reads as unanswered, because that parent is the
pre-review commit. `parents(first: 1)` returns the date beside the
`totalCount` already fetched, so it stays one request.

**`reviewRequestedAt` could date a request that no longer existed.**
`reviewRequests` says who is on the hook NOW and carries no date; a
`ReviewRequestedEvent` carries the date and survives the request being
withdrawn. Nothing tied the two to the same reviewer, so a request created
after the review and then removed could date a standing older one, and the
live rejection was hidden behind "waiting for review" again — the bug this
branch fixes, one step rarer. The date now comes from the newest event whose
reviewer is still on the request list, matched by login (User/Bot) or slug
(Team).

Four tests added, all four verified to fail against the reviewed behaviour.
The enlarged query was re-validated against the live GraphQL API. Spec
updated: a merge is transparent to the push rule, not disqualifying.
@patzick

patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Re-review — both findings fixed

Head is now 87e883b3. Both findings from the previous pass are resolved and re-checked; no new findings.

✅ Blocker — merge head erasing an earlier answer — fixed

github.ts now judges the push by the newest commit that is actual work:

const workCommittedAt =
  (pr.headParentCount ?? 1) < 2 ? pr.headCommittedAt : pr.headFirstParentCommittedAt;
const pushed = isAfter(workCommittedAt, pr.changesRequestedAt);

The merge's first parent is the branch's previous tip, so the fix underneath a conflict-resolution merge keeps its answer. Re-checked against all three cases:

sequence before now
fix pushed after review, then a merge lands on top changes-requested (wrong) review-required
plain "Update branch", nothing ever fixed changes-requested changes-requested
merge with no first-parent date changes-requested (conservative)

✅ Major — reviewRequestedAt dating a withdrawn request — fixed

standingReviewRequestedAt now takes the newest ReviewRequestedEvent whose reviewer is still on the request list, matched by login (User/Bot) or slug (Team). The withdrawn-request path is covered, and so is its mirror — a standing reviewer asked after the review still hands the ball back, so the correlation cannot pass by simply never matching.

🧪 Validation

typecheck ✅ · test:unit 35/35 ✅ · build ✅ (check:pack ok, 475 files) · test:package 15/15 ✅.

Forge and chip suites: 228/228. The forge suite is now 169 tests (+4); each new test was verified to fail against the pre-fix behaviour, so none can pass vacuously. npm test overall leaves the same 8 environment-dependent suites failing (git-worktree, git, git-changes, health-forge, automations-api, automations-gate, projects-api, route-parity) — unchanged from before this branch.

The enlarged query (parents(first: 1), reviewRequests(first: 20), timelineItems(last: 20)) was re-validated against the live GraphQL API and returns the expected shape. Still one request per batch — every field rides the existing aliased node.

🏁 Verdict

Both findings cleared. The blocking constraint is unchanged and is not something this run can lift: #909 is authored by the account running the review, so GitHub will not accept a formal APPROVE from it, and the repository's review requirement still needs an independent reviewer. Pipeline label moves to merge-queue to reflect that the review findings are closed, with needs-qa retained for the QA gate.

@patzick patzick added merge-queue Approved, ready to merge and removed changes-requested Reviewer requested changes in-progress Cezar agent is actively working this issue labels Aug 20, 2026
@patzick

patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

🤖 om-auto-review-pr completed: CHANGES REQUESTED → fixed in 87e883b3. Lock released.

Autofix ran (PR authored by the running account, so autofix-eligible without --autofix): both findings fixed, 4 regression tests added, full validation gate re-run, pushed. Pipeline label moved changes-requestedmerge-queue; needs-qa retained for the QA gate.

⚠️ Still needs an independent reviewer. GitHub refuses a formal review verdict from the PR's own author, so nothing this run posted can satisfy the repository's review requirement — #909 stays BLOCKED until someone else approves it. No CI follow-up is pending: checks were already green before the autofix commit and will re-run on 87e883b3.

@patzick

patzick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

🤖 om-auto-review-pr — CI result

All checks pass on 87e883b3, the autofix head:

check result time
Publish npm snapshot ✅ pass 43s
Unit, build, E2E, and package ✅ pass 4m2s
license/cla ✅ pass

Run: https://github.com/open-mercato/cezar/actions/runs/32376832429

CI agrees with the local gate, so the review findings and the checks are both clear. ci-monitoring removed; no further follow-up will come from this agent.

The PR remains BLOCKED on its review requirement alone — GitHub will not take an approval from the PR's own author, so #909 needs an independent reviewer. needs-qa is also still set, and with qaGate on it holds the merge until qa-approved is added by manual QA.

@patzick
patzick merged commit 185c68a into main Aug 20, 2026
3 checks passed
@patzick
patzick deleted the cez/7474348a branch August 20, 2026 13:59
patzick added a commit that referenced this pull request Aug 21, 2026
…ibution)

One conflict, in CHANGELOG.md: both sides appended to Unreleased. Resolved by
keeping both — this branch's `## 🔧 Changed` seed-once entry and its automations
fix, plus main's four Fixes entries from #901, #904, #905 and #909, merged into
a single `## 🐛 Fixes` list.

Everything else auto-merged, including server.ts, which both sides touched.
Verified the branch's own changes survived: the coordinator's `pinned` wiring,
the root-keyed automation store, and both copy fixes.
sheeerth pushed a commit to sheeerth/cezar that referenced this pull request Aug 23, 2026
…us facet

The Tasks table's search box only ever matched title, branch and workflow, so a
task known by its tracker number — the way it is named in a PR body or a standup
— could only be found by remembering its title. `referenceNeedle` recognizes
`open-mercato#909`, a bare `909`, `pr 909` and `issue 42`, and `filterRuns` answers them
against `taskReferences`: the same list the row's own chip is built from, so
search can never surface a number the table would not show.

Alongside it, the pure model the status filter needs: `TaskListFilters`,
`filterTaskList` (facet AND search), `statusFacetOptions` with counts computed
the way the global page computes them, and the toggle/count helpers the Clear
affordance reads. The facet's vocabulary is the status PILL's word
(`deriveAttention().label`), not `RunRecord.status`, so it can offer
`scheduled` and `monitoring` — sub-states the record spells as failed/running —
and can never disagree with the column beside it.
pat-lewczuk added a commit that referenced this pull request Sep 4, 2026
Stamp the release set (contract, api-client, cezar, web, alias-cezar) and the
lockfile 0.10.0 -> 0.10.1, with the intra-release ranges rewritten to ^0.10.1.
A patch bump: every change since 0.10.0 is a feature or a fix, no breaking
surface.

Turn `# Unreleased` into `# 0.10.1 (2026-09-04)` and make it a real release
entry:

- keep the five essay-length entries already drafted (pin tasks, agent-account
  Continue, the CEZ:ASK brace repair, the merge-conflict chip, and the three
  reference-chip fixes), fixing their PR citations along the way (the pin
  feature cited its tracking issue, #935, instead of the PR that shipped it,
  #938; the CEZ:ASK and merge-conflict entries had no citation at all)
- add the ~14 PRs that merged since with no changelog note at all: PDF/TXT/MD
  composer attachments (#951), cross-repo reference-chip safety (#946), a
  stale review request or an "Update branch" merge clearing a rejection
  (#909), cross-project task 404s (#905), the Changes tab's independent
  scroll (#918), a clearable composer skill pill (#919), a resumed session
  keeping its step's tools (#928), Alt quick replies eating Polish
  characters (#943), host-local Claude model discovery (#841), GitHub search
  across every state (#732), two mobile run-header/history fixes (#764,
  #873), and a fresh task's /skill expansion (#947)
- add a CI/CD & Infrastructure section for the GITHUB_RUN_ATTEMPT e2e fix
  (#911)
- add a Highlights paragraph and credit the seven contributors

No code changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working merge-queue Approved, ready to merge needs-qa Requires manual QA before merge priority-medium Ordinary bug or feature risk-low Isolated, low blast radius

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant