You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: pull request mirroring for GitLab, Bitbucket, Azure DevOps, and CodeCommit (#46)
* refactor(vcs): split reopen from close, add BaseSHA to review comments
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.
* feat(gitlab): mirror merge requests, notes, and approvals
* feat(bitbucket): mirror pull requests, comments, and participant verdicts
* feat(azuredevops): mirror pull requests, threads, and reviewer votes
* feat(codecommit): mirror pull requests, comments, and approvals
* feat(gitsync): pull request mirroring for every provider type
docs: what each provider cannot represent
* fix(prsync): stop updating a closed destination pull request
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.
* fix(vcs): correct provider defects found in review of #46
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.
---------
Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+9Lines changed: 9 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
8
8
## [Unreleased]
9
9
10
+
### Added
11
+
- Pull request mirroring now works for every provider type, in any direction: GitLab, Bitbucket Cloud, Azure DevOps, and AWS CodeCommit join GitHub. Each maps onto the same model, and the runbook carries a table of what each provider cannot represent
12
+
- CodeCommit is in scope after all. It was excluded when the work was planned on the grounds that it had no pull request API worth mirroring, which was wrong: it has a full one, already present in the pinned SDK, and AWS returned the service to general availability on 2025-11-24 after closing it to new customers in 2024
13
+
14
+
### Changed
15
+
-`vcs.PullRequestSpec` carries `SyncLabels`, so a provider can tell "the source has no labels", where a destination label should be removed, from "label mirroring is off", where a label somebody added at the destination must be left alone. Labels are now reconciled rather than only added: one removed at the source is removed at the destination
16
+
-`vcs.PullRequestWriter` closes a pull request but no longer reopens one. Bitbucket Cloud has no reopen endpoint and CodeCommit permits only the `OPEN` to `CLOSED` transition, so reopening is now an optional `vcs.PullRequestReopener` capability. A destination that lacks it leaves the pull request closed, warns once, and records the divergence rather than opening a second pull request for the same work or retrying on every run
17
+
-`vcs.ReviewComment` carries `BaseSHA` alongside `CommitSHA`: GitLab refuses to anchor an inline comment without base, head, and start SHAs, and CodeCommit needs a before and after commit id. A source that cannot supply them downgrades the comment to a discussion comment rather than dropping it
**Pull request heads (opt in):** set `pull_requests.enabled: true` on a mirror and the commits behind each open pull request opened from a **fork** are pushed to the destination as a branch, default `syncerd/pr/<number>`. Without it those commits reach the destination nowhere, since a fork pull request has no branch in the source repository. A pull request opened from a source branch already mirrors under its own name and gets no second copy. The pull request itself is not recreated at the destination yet. Off by default because a fork head is third-party code, and a destination that builds on branch push would run it: read the security note in [docs/git-sync-runbook.md](docs/git-sync-runbook.md#mirroring-pull-request-heads) before enabling it.
382
382
383
-
**Mirrored pull requests (opt in):** add `pull_requests.mirror_objects: true` and each open source pull request is recreated at the destination as a real pull request, kept in step on every run, with its discussion, inline review comments, and review verdicts. The source is the single authority; nothing written at the destination flows back. A merged source pull request is closed at the destination with a note naming the merge commit, never merged there, because a destination merge would diverge from the mirrored base branch. Review verdicts are mirrored as attributed text rather than real approvals. Mentions and issue references in mirrored text are neutralised so a mirror never notifies or cross links anyone. GitHub on both ends for now.
383
+
**Mirrored pull requests (opt in):** add `pull_requests.mirror_objects: true` and each open source pull request is recreated at the destination as a real pull request, kept in step on every run, with its discussion, inline review comments, and review verdicts. The source is the single authority; nothing written at the destination flows back. A merged source pull request is closed at the destination with a note naming the merge commit, never merged there, because a destination merge would diverge from the mirrored base branch. Review verdicts are mirrored as attributed text rather than real approvals. Mentions and issue references in mirrored text are neutralised so a mirror never notifies or cross links anyone. All five provider types work, in any direction; the runbook has a table of what each one cannot represent (Bitbucket and CodeCommit cannot reopen, neither has labels, CodeCommit has no drafts).
384
384
385
385
Run with `--dry-run` to print the ref changes each mirror would make, per repository, without creating, pushing, or deleting anything.
returnnil, nil, fmt.Errorf("mirror %q: pull_requests is enabled but source provider %q (type %q) cannot list pull requests; GitHub is supported today and the remaining provider types arrive in a later release", mc.Name, mc.Source, src.Type())
147
+
returnnil, nil, fmt.Errorf("mirror %q: pull_requests is enabled but source provider %q (type %q) cannot list pull requests", mc.Name, mc.Source, src.Type())
returnnil, nil, fmt.Errorf("mirror %q: pull_requests.mirror_objects is set but destination provider %q (type %q) cannot write pull requests; GitHub is supported today and the remaining provider types arrive in a later release", mc.Name, mc.Destination, dst.Type())
160
+
returnnil, nil, fmt.Errorf("mirror %q: pull_requests.mirror_objects is set but destination provider %q (type %q) cannot write pull requests", mc.Name, mc.Destination, dst.Type())
returnnil, nil, fmt.Errorf("mirror %q: pull request comments are enabled but source provider %q (type %q) cannot read them; set comments: false and reviews: false, or wait for that provider", mc.Name, mc.Source, src.Type())
165
+
returnnil, nil, fmt.Errorf("mirror %q: pull request comments are enabled but source provider %q (type %q) cannot read them; set comments: false and reviews: false", mc.Name, mc.Source, src.Type())
166
166
}
167
167
destConv, ok=dst.(vcs.PullRequestConversation)
168
168
if!ok {
169
-
returnnil, nil, fmt.Errorf("mirror %q: pull request comments are enabled but destination provider %q (type %q) cannot write them; set comments: false and reviews: false, or wait for that provider", mc.Name, mc.Destination, dst.Type())
169
+
returnnil, nil, fmt.Errorf("mirror %q: pull request comments are enabled but destination provider %q (type %q) cannot write them; set comments: false and reviews: false", mc.Name, mc.Destination, dst.Type())
0 commit comments