Skip to content

feat(commits): Add provider-agnostic PR-iteration primitives - #126

Merged
vaind merged 7 commits into
mainfrom
ivan/cw-1734-pr-iteration-primitives
Aug 6, 2026
Merged

feat(commits): Add provider-agnostic PR-iteration primitives#126
vaind merged 7 commits into
mainfrom
ivan/cw-1734-pr-iteration-primitives

Conversation

@vaind

@vaind vaind commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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:

  1. GitLab compare never populates behind_by. map_commit_comparison sets ahead_by = len(commits) and leaves behind_by off 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.
  2. create_commit has 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.

Update: this PR originally carried a third primitive — commit account logins (author_login / committer_login) for bot detection. That change shares no code with the two above and has a very different risk profile, so it has been split out to #127 and reverted here.

Approach

Both additions are additive: new NotRequired keys and defaulted keyword-only params, no signature breaks. Existing tests pass unmodified.

1. GitLab behind_by — internal reverse compare, opt-in via include_behind

compare_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_by then 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_base endpoint. GET /repository/merge_base gives exact ancestry but no counts, so it cannot truthfully populate an int field: it would force either a lying sentinel (behind_by = 1 meaning "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_commits callers 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 "make behind_by trustworthy everywhere". The reverse call is deliberately unpaginated: GitLab's compare endpoint is not paginated and its commits array is always complete, so a single call is the full count (the same basis as ahead_by); this is noted in the docstring.

2. expected_head_sha on create_commit

A lease on the destination, not a parent selector — parent_sha still chooses the parent. It cannot be combined with create_branch (no head to lease) or force (a forced update overwrites the head unconditionally and cannot honor a lease); either combination raises resource_bad_request.

  • GitHub — atomic. The lease is enforced twice, and both checks earn their keep. The pre-check runs before any write, so a head that has already moved is rejected before any blob, tree, or commit object is created, and it is the only guard that catches a branch rewound to an ancestor of 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).
  • GitLab — check-then-act, not atomic. There is no server-side CAS anywhere on this path: the commits API takes no expected parent, the branches API has no update endpoint, and GraphQL's commitCreate matches REST. The strongest available implementation is: read the head, write, then verify the created commit's parent_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. Missing parent_ids fails closed.

Because GitHub cannot express a lease-guarded force (no compare-and-swap on the ref PATCH), force=True and expected_head_sha are 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 plain SCMCodedError subclass, so it registers itself, round-trips over RPC to the concrete class, and is catchable as except StaleBranchHead:. The existing test_errors.py registry/round-trip tests cover it automatically.

Testing

uv run pytest tests/, uv run ruff check src tests, and uv run mypy src are all green: 864 passed, mypy clean over 17 source files.

New coverage:

  • GitLab classification — parametrized over identical / ahead / behind / diverged, asserting the counts and that the two calls swap from/to; plus the default path issuing one call with no behind_by key, and the reverse call staying unpaginated.
  • expected_head_sha per 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 to stale_branch_head with the cause preserved, and the same 422 propagating unchanged without the param; GitLab's post-write parent_ids mismatch and unverifiable-parents cases; create_branch and force rejection on both.
  • Action forwarding — both new params verified to reach the provider, since nothing else enforces the actions.py wrapper staying in sync.

Per AGENTS.md, both new params were threaded through all six surfaces: actions.py, both providers, BaseTestProvider, both bin/*-client CLIs (usage docstring, argparse, call site), and provider tests.

Fixes CW-1734

🤖 Generated with Claude Code

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.
@vaind
vaind requested a review from a team as a code owner July 31, 2026 02:51
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

CW-1734

Comment thread src/scm/providers/gitlab/provider.py
Comment thread src/scm/providers/github/provider.py
Comment thread src/scm/providers/github/provider.py
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>
@vaind
vaind force-pushed the ivan/cw-1734-pr-iteration-primitives branch from 4164aee to 536bf24 Compare August 3, 2026 08:02
Comment thread src/scm/providers/gitlab/provider.py
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>
@vaind
vaind marked this pull request as ready for review August 3, 2026 09:11

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/scm/providers/github/provider.py
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>
vaind added a commit that referenced this pull request Aug 4, 2026
## 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 billyvg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread src/scm/actions.py Outdated
Comment thread src/scm/providers/gitlab/provider.py Outdated
vaind and others added 2 commits August 5, 2026 14:32
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>
@vaind
vaind merged commit 8da20ac into main Aug 6, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants