Skip to content

feat: pull request mirroring for GitLab, Bitbucket, Azure DevOps, and CodeCommit - #46

Merged
clouddrove-ci merged 8 commits into
masterfrom
feat/pr-sync-p3
Aug 19, 2026
Merged

feat: pull request mirroring for GitLab, Bitbucket, Azure DevOps, and CodeCommit#46
clouddrove-ci merged 8 commits into
masterfrom
feat/pr-sync-p3

Conversation

@clouddrove-ci

Copy link
Copy Markdown
Member

Final phase of the pull request replication program. Every provider type can now mirror pull requests, in any direction.

Scope correction

CodeCommit was excluded when this program was planned, on my claim that it had no pull request API worth mirroring. That was wrong on both counts. It has a full one, already in the pinned SDK, and AWS returned the service to general availability on 2025-11-24 after closing it to new customers in 2024. It is included here, so the answer to "are all the other providers fixed" is yes rather than four out of five.

Two contradictions with P2, resolved rather than papered over

The providers disagree about what a pull request has, and two of the disagreements broke rules P2 had already implemented.

Reopen is not universal. P2 reopens a destination pull request that was closed by hand while its source is open, because the source is the authority. Bitbucket has no reopen endpoint at all, and CodeCommit documents that the only valid status transition is OPEN to CLOSED. So PullRequestWriter now closes only, and reopening is an optional PullRequestReopener. A destination without it warns once, records the divergence in state, and leaves the pull request closed. It does not open a second pull request for the same work, and it does not retry on every run: the recorded divergence satisfies the skip check, which otherwise would have cost a pointless write per run forever.

The anchor model is not GitHub's. GitLab refuses to anchor an inline comment without base, head, and start SHAs; CodeCommit needs a before and after commit id. ReviewComment gained BaseSHA alongside CommitSHA. Where a source cannot supply them, the comment downgrades to a discussion comment through the path P2 already built.

What each provider cannot represent

Documented in the runbook as a table, because an operator choosing a destination deserves to know before they choose it:

GitHub GitLab Bitbucket Azure DevOps CodeCommit
Reopen yes yes no yes no
Draft yes Draft: title prefix yes create only no
Labels yes yes no yes no
Comment deletion removed removed removed tombstone content blanked

Nothing here fails a run; a missing capability is logged and skipped, never faked.

Two provider quirks worth knowing:

  • GitLab has no writable draft field. A merge request is a draft because its title starts with Draft:. This is the one place SyncerD does not copy a title verbatim, and the prefix is stripped when the source leaves draft.
  • CodeCommit is the expensive one. ListPullRequests returns ids only, so a listing costs one GetPullRequest per id, and there is no branch filter at all, so FindPullRequest enumerates and matches client side. The enumeration is cached per repository per run so the listing and every find share one pass.

Testing

Per provider httptest suites, plus the CodeCommit fake api factory (no network, no credentials). A build-time matrix exercises all 20 source-to-destination pairs, and a separate test asserts the reopen capability is present on exactly GitHub, GitLab, and Azure DevOps and absent on Bitbucket and CodeCommit, since the engine branches on that type assertion. Each provider's tests also assert no merge endpoint is ever reached and no token leaks into an error.

verify-syncerd full tier passes: static and build, helm, live Docker Hub to local registry sync with state skip, Slack failure notification, Docker image build, clean teardown.

Bitbucket Cloud has no reopen endpoint and CodeCommit forbids the CLOSED to
OPEN transition, so reopening cannot be part of the writer contract every
destination satisfies. PullRequestWriter now closes only, and reopening is
an optional PullRequestReopener a destination may implement. A destination
without it records the divergence and leaves the pull request closed rather
than opening a second one for the same work.

GitLab requires base, head, and start SHAs to anchor an inline comment, and
CodeCommit requires a before and after commit id, so ReviewComment carries
BaseSHA alongside CommitSHA. GitHub and Bitbucket ignore it.
docs: what each provider cannot represent
A destination that cannot reopen, which is Bitbucket and CodeCommit, fell
through to UpdatePullRequest on a pull request it had just decided to leave
closed. Both providers reject an update to a closed pull request, so the
call errored before the divergence was ever recorded: the warning fired
every run, a failure was appended every run, and the mirror never settled.
The same path ran for a merged source whose destination was already closed
whenever new activity advanced its timestamp.

An update now runs only while the destination is open, and an unfixable
divergence records itself and returns.

A recorded divergence is also re-checked each run rather than trusted, so a
destination somebody reopened by hand is picked back up; previously the
marker made statesAgree return true forever and the pull request could
never be closed again.

A source that reports no update timestamp, which Azure DevOps does not, is
no longer watermarked. Skipping on a zero time mirrored such a pull request
once and then froze it: no title, body, comment, or verdict change ever
reached the destination again.
CodeCommit's pull request cache outlived the run that filled it. A provider
is built once and reused for every cron tick, so a CodeCommit source served
the first run's snapshot forever: later pull requests were never mirrored,
closed ones never closed, and the repository was skipped as unchanged
because the same stale set fed the fingerprint. The listing now
re-enumerates and the cache only serves the finds that follow it.

CodeCommit's FindPullRequest searched open pull requests only, so a
destination pull request somebody had closed was invisible and a lost state
file produced a second one for the same branch. A missed lookup now
enumerates the closed ones too.

CodeCommit treated every failure to post a located comment as a rejected
anchor, so throttling or an expired credential permanently downgraded the
comment to a discussion comment. Only genuine location errors downgrade.

CodeCommit's ListReviews answered "no reviews" when the underlying call
failed, and the caller deletes mirrored comments it no longer sees at the
source, so one throttled call deleted every mirrored verdict. It returns
the error now.

Azure DevOps stood the creation date in for a missing update timestamp,
which froze the engine's watermark and mirrored each pull request exactly
once. It reports no timestamp instead, and the engine reconciles every run.

Azure DevOps added labels and never removed them, re-POSTing every label on
every run and failing the pull request when one already existed. Labels are
reconciled against the source, and truncation counts characters rather than
bytes so a non-ASCII description is neither cut to a third of the limit nor
split through a rune.

GitLab decided a comment was on the left side by testing an empty path, but
GitLab populates new_path on both sides of a text diff, so every deleted
line comment anchored to line 0 on the right. It tests the line instead.
GitLab's transient locked state is no longer read as closed, which used to
close the destination and post "closed without merging" moments before the
source became merged.

Bitbucket omitted draft on update, so a pull request that left draft at the
source stayed a draft at the destination, and its lookup missed superseded
pull requests.

GitHub never reported a base SHA, so every inline comment mirrored to
GitLab or CodeCommit, which both require one, silently downgraded to a
discussion comment.

Turning comments or reviews off deleted everything previously mirrored in
that category, because nothing in it appeared in the seen set. Deletion is
now scoped to categories the run actually listed.
@clouddrove-ci
clouddrove-ci merged commit 2b9a50b into master Aug 19, 2026
3 checks passed
clouddrove-ci added a commit that referenced this pull request Aug 19, 2026
* chore(release): 0.2.1

Cuts the Unreleased section carrying destination pull request mirroring,
the four remaining provider implementations, and the fixes from the review
of #46, and repoints the Action examples at the new tag.

Also corrects the 0.2.0 section, which claimed destination pull request
mirroring. That entry was written under Unreleased on the #44 branch, and
by the time it merged the release PR had already renamed that heading to
0.2.0, so a clean auto-merge filed a feature under a tag that never
contained it. The 0.2.0 section now matches what v0.2.0 actually shipped.

* fix(azuredevops): send a username with the PAT on the git transport

pat mode presented an empty username with the PAT as the password. SyncerD
hands git its credential through a helper that prints "username=<value>",
and git reads an empty value as no username at all: it then tries to ask,
terminal prompts are disabled, and every clone and push failed with "could
not read Username", which names nothing about the provider or the config
that produced it.

Azure DevOps ignores the username on this path and authenticates on the
token alone, so the value is free. The organisation name is sent because it
makes the credential legible in a trace without being a secret.

A basic credential carrying a password with no username is now rejected by
the runner as well, so a future provider with the same gap fails with a
sentence that names the cause rather than a bare git error. A bearer
credential still needs no username, and an empty credential, which is what
a local path uses, stays valid.

The REST API path was never affected: it carries the token in an
Authorization header, where an empty username is what Azure DevOps
documents.

---------

Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
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