feat(commits): Add provider-agnostic PR-iteration primitives - #126
Conversation
Consumers replacing a force-push with a lease check need to classify how a branch moved relative to the last commit they pushed, and to append only if it has not moved underneath them. Three gaps made that impossible to express provider-agnostically, so each consumer would have had to special-case GitLab. - GitLab compare never populated `behind_by`, so a branch reset to an ancestor read as *identical* — the most dangerous possible answer for a lease check. An opt-in `include_behind` buys the count with a reversed compare. - `create_commit` had no way to say "only if the head is still X", so callers fell back to force-pushing. `expected_head_sha` makes that a first-class, typed contract with a documented per-provider atomicity guarantee. - The GitHub commit payloads carry account logins that the mapping dropped, leaving consumers to do bot detection off spoofable git identities.
A forced ref update overwrites the branch head unconditionally, so it can never honor a lease. On GitHub the pre-check alone could still be defeated by a push landing between it and the forced update: the concurrent push was silently overwritten and the call reported success, which contradicts the documented lease guarantee (and is weaker than GitLab, whose post-write parent check still detects the race). Rather than ship a best-effort force-with-lease that GitHub has no atomic primitive to back, reject the contradictory combination up front on both providers so the contract stays uniform, and drop the docstring claim that force downgrades to GitLab's check-then-act guarantee. Also correct the GitHub create_commit docstring: the pre-check prevents orphaned objects only when the head has already moved; a push caught in the race window by the fast-forward-only ref update still leaves the just-created objects for GitHub to garbage-collect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4164aee to
536bf24
Compare
The author_login/committer_login attribution shares no code with this PR's branch-movement classification (behind_by) and expected_head_sha lease work, and carries a very different risk profile, so it stands alone in #127 for isolated review and revert. Squash-merge keeps this back-and-forth out of main's history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1c54ece. Configure here.
GitHub returns 422 from the ref update for more than a non-fast-forward caused by a concurrent push: a protected-branch/ruleset denial, or a parent_sha that does not descend from the head, produce the same status. Mapping every 422 to StaleBranchHead mislabeled those as a lost lease, which could send a consumer into a pointless re-read-and-retry loop against an error that will never clear. Re-read the branch on 422 and raise StaleBranchHead only when the head has actually moved off expected_head_sha; otherwise let the original ResourceUnprocessableContent propagate unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Problem Consumers doing bot detection (e.g. telling `getsantry[bot]`'s commits from a human's) had only the git identity to go on — `author.email` / `author.name` nested under `commit`. That identity is self-asserted and trivially spoofable, so it's the wrong thing to gate on. GitHub's commit and compare payloads already carry the resolved service-provider **account** (`author.login` / `committer.login`) *alongside* the git identity, but the mapping dropped it. ## Approach `Commit` (hence `CommitWithChanges`) and `PullRequestCommit` gain `author_login` / `committer_login` as `NotRequired[str]`, populated by the GitHub mapping wherever the payload carries the user objects: compare commits, `get_commit`, `get_commits`, `get_commits_by_path`, and `get_pull_request_commits`. - **`NotRequired` rather than a required `None`-valued key** so existing code that constructs a `Commit` literal keeps type-checking. - **Absent attribution omits the key** rather than setting `None` — GitHub nulls these for commits whose email matches no account, and GitLab's payloads carry no user object at all, so its mapping never populates them. Both facts are documented on the `Commit` docstring and covered by tests (GitHub omit-when-unattributed, GitLab always-omitted). This is a purely additive mapping/type change with no runtime coupling to anything else. ## Split note Carved out of #126, which bundled three independent primitives. This attribution change shares no code with that PR's branch-movement classification and `expected_head_sha` lease work, so it stands alone here for isolated review and revert. #126 reverts the corresponding lines. ## Testing `uv run pytest tests/` (849 passed), `uv run ruff check src tests`, and `uv run mypy src` all green. New coverage: GitHub logins on compare / `get_commit` / PR commits, GitHub omission when unattributed, and GitLab always omitting them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
billyvg
left a comment
There was a problem hiding this comment.
Two blocking findings from a review of this branch, both verified by running the real provider code against the PR head (870 passed / mypy clean / ruff clean locally, so these are semantic, not build, issues). Non-blocking notes were left out deliberately — these two are the ones I think should be resolved before merge.
Overall: the motivation is real, the additive design is right, and the honesty about GitLab's weaker guarantee is unusually good. Both findings below are about a documented guarantee being stronger than the code delivers.
🤖 This review was generated by Claude (Claude Code) at the request of @billyvg. Findings were verified by execution against this branch, but please treat the reasoning as a starting point for discussion rather than a verdict.
The action-level docstring promised callers that GitHub's expected_head_sha is atomic, on the grounds that the fast-forward-only ref update rejects any concurrent push. That holds only for a push that adds commits. If the branch is rewound to an ancestor of expected_head_sha, the new commit still descends from the rewound head, so GitHub accepts the ref update as a legitimate fast-forward: the write succeeds, the rewind is silently undone, and the caller is told the lease held. The pre-write head check is what normally catches a rewind, but it runs before the blob/tree/commit creation calls, so the window it leaves open is seconds wide rather than negligible. Nothing on this path can close it — GitHub's REST ref update has no compare-and-swap — so state the guarantee at the strength the code actually delivers and point at the GraphQL mutation that would deliver more. The provider docstring already drew the rewind distinction correctly; it now stops claiming the ref update closes the race it only narrows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GitLab reports no ahead/behind counts, so ahead_by is derived from the length of the returned commits array while include_behind buys behind_by from a second, deliberately unpaginated compare. Asking for both alongside pagination therefore returned two numbers measured against different bases: a 200-commit divergence read 20 at a time reported ahead 20, behind 0, which classifies as a clean fast-forward. That is wrong in the unsafe direction — the caller concludes nothing was lost and appends, which is the failure this whole surface exists to prevent. Both of the honest answers (a page, or comparable counts) are still available; only the combination is refused, matching how force and create_branch are already rejected against expected_head_sha. GitHub reads both counts off the response body and stays unaffected, which a test now pins. Documents the remaining GitLab limitation rather than papering over it: the compare response caps its commits array and signals a timed-out comparison via compare_timeout, so both counts saturate silently on a wide divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Problem
Seer is replacing its PR-iteration force-push with a lease check plus a 3-way content reconcile. Before updating a PR branch it needs to classify how the branch moved relative to the last commit it pushed (identical / ahead / behind / diverged) and append a commit only if the head has not moved underneath it.
Two primitives needed for that are missing or weak here, and without them each consumer has to special-case GitLab:
behind_by.map_commit_comparisonsetsahead_by = len(commits)and leavesbehind_byoff entirely, so a consumer can only ever see "ahead" or "identical". A branch reset to an ancestor reads as identical — the most dangerous possible answer for a lease check. GitHub populates both.create_commithas no compare-and-swap contract. Callers who want "append only if the head is still X" have no way to say so, so they force-push instead.Approach
Both additions are additive: new
NotRequiredkeys and defaulted keyword-only params, no signature breaks. Existing tests pass unmodified.1. GitLab
behind_by— internal reverse compare, opt-in viainclude_behindcompare_commits(..., include_behind=True)issues a second, reversed compare (from=end, to=start). GitLab's compare defaults to merge-base semantics, so the reverse call's commit count is the behind count.behind_bythen means exactly what it means on GitHub — a merge-base-relative commit count — and a consumer writes one classification rule for both providers.Why not the
merge_baseendpoint.GET /repository/merge_basegives exact ancestry but no counts, so it cannot truthfully populate anintfield: it would force either a lying sentinel (behind_by = 1meaning "some") or a second, differently-shaped field that only exists on GitLab — which puts the provider branch right back into consumer code, the thing this library exists to remove. The reverse compare costs a cheap extra GET and gives the real number.Why opt-in rather than unconditional. Most
compare_commitscallers want the diff, not the counts, and doubling every GitLab compare to serve the minority is the wrong default. GitHub accepts the flag and ignores it — it already returns both counts — so the flag reads as "makebehind_bytrustworthy everywhere". The reverse call is deliberately unpaginated: GitLab's compare endpoint is not paginated and itscommitsarray is always complete, so a single call is the full count (the same basis asahead_by); this is noted in the docstring.2.
expected_head_shaoncreate_commitA lease on the destination, not a parent selector —
parent_shastill chooses the parent. It cannot be combined withcreate_branch(no head to lease) orforce(a forced update overwrites the head unconditionally and cannot honor a lease); either combination raisesresource_bad_request.expected_head_sha— the ref update would accept that as a legitimate fast-forward. The fast-forward-only ref PATCH is then the atomic guard, closing the race the pre-check cannot; its 422 is mapped to the typed error (scoped to the ref update, and only when the caller opted into the lease).commitCreatematches REST. The strongest available implementation is: read the head, write, then verify the created commit'sparent_ids. A push landing inside that window still wins, and the error is raised after the commit exists and the branch has already moved. This is documented on the action, on the provider method, and on the exception, and is covered by a test that asserts the POST already happened. Missingparent_idsfails closed.Because GitHub cannot express a lease-guarded force (no compare-and-swap on the ref PATCH),
force=Trueandexpected_head_shaare mutually exclusive rather than a best-effort combination that silently loses a concurrent push — rejected up front on both providers so the contract is uniform.Typed error:
StaleBranchHead/stale_branch_head, a plainSCMCodedErrorsubclass, so it registers itself, round-trips over RPC to the concrete class, and is catchable asexcept StaleBranchHead:. The existingtest_errors.pyregistry/round-trip tests cover it automatically.Testing
uv run pytest tests/,uv run ruff check src tests, anduv run mypy srcare all green: 864 passed, mypy clean over 17 source files.New coverage:
from/to; plus the default path issuing one call with nobehind_bykey, and the reverse call staying unpaginated.expected_head_shaper provider — happy path; stale head rejected before any write (GitHub asserts the call list is a single GET); GitHub's non-fast-forward 422 mapped tostale_branch_headwith the cause preserved, and the same 422 propagating unchanged without the param; GitLab's post-writeparent_idsmismatch and unverifiable-parents cases;create_branchandforcerejection on both.actions.pywrapper staying in sync.Per
AGENTS.md, both new params were threaded through all six surfaces:actions.py, both providers,BaseTestProvider, bothbin/*-clientCLIs (usage docstring, argparse, call site), and provider tests.Fixes CW-1734
🤖 Generated with Claude Code