Skip to content

Commit 3825739

Browse files
feat(prsync): recreate pull requests, discussion, and reviews at the destination (#44)
* feat(prsync): body composition with markers and mention safety * feat(vcs): pull request writer and conversation interfaces * feat(state): record mirrored pull requests * feat(github): create, update, and close destination pull requests * feat(github): read and write pull request conversation * feat(prsync): destination pull request lifecycle and conversation * feat(gitsync): wire destination pull request mirroring * docs: destination pull request mirroring --------- Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent 8e5c84e commit 3825739

23 files changed

Lines changed: 2799 additions & 17 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
## [0.2.0] - 2026-08-19
1111

1212
### Added
13+
- `git-sync` can recreate pull requests at the destination. With `pull_requests.mirror_objects` on a mirror, each open source pull request becomes a real destination pull request, updated on every run, carrying its discussion comments, inline review comments anchored to the diff, review verdicts, and labels. A merged source pull request closes the destination one with a comment naming the source merge commit rather than merging it, because a destination merge would create a different commit that the branch mirror then overwrites. Review verdicts are mirrored as attributed text, never as real approvals, so a mirror cannot satisfy branch protection that no human satisfied. Mentions and issue references in mirrored text are neutralised, so mirrored content never notifies a destination account or cross links an unrelated issue. Everything SyncerD writes carries a hidden marker, so a lost state file causes re-inspection rather than duplicate pull requests, and only comments SyncerD wrote are ever edited or deleted. With `mirror_objects` on, every mirrored pull request gets a `branch_prefix` branch rather than fork heads alone, so a destination pull request has one uniform head name. GitHub source and destination for now; other providers are rejected at startup
1314
- `git-sync` can mirror the commits behind open pull requests. A pull request opened from a fork has no branch in the source repository, so its commits previously reached no destination at all; with `pull_requests.enabled` on a mirror, the head is pushed as an ordinary branch under `branch_prefix` (default `syncerd/pr/<number>`), and the default mirror push mode prunes it once the pull request closes. A head that lives in the source repository already arrives with the ordinary branch mirror and gets no second copy. A repository whose own branches sit under the prefix is refused rather than silently overwritten, and one unreachable fork head is a warning rather than a failed repository. Off by default: a fork head is third party code, and a destination that builds on branch push would run it. GitHub sources only for now; the pull request objects themselves are not recreated at the destination yet
1415

1516
### Fixed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ export SYNCERD_GIT_GL_TOKEN=your-gitlab-token
380380

381381
**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.
382382

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.
384+
383385
Run with `--dry-run` to print the ref changes each mirror would make, per repository, without creating, pushing, or deleting anything.
384386

385387
**Limitations:**

docs/git-sync-runbook.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,56 @@ Under the default `mirror` push mode the branch disappears from the destination
623623
on the first run after the pull request closes: the source stops listing it, and
624624
prune removes what the source no longer has.
625625

626-
The pull request object itself, its title, discussion, and reviews, is not
627-
recreated at the destination. Only the commits are.
626+
### Recreating the pull requests themselves
627+
628+
Set `mirror_objects` and each open source pull request is recreated at the
629+
destination as a real pull request, updated on every run, with its discussion,
630+
its inline review comments, and its review verdicts:
631+
632+
```yaml
633+
pull_requests:
634+
enabled: true
635+
mirror_objects: true # default false, requires enabled
636+
comments: true # default true once mirror_objects is on
637+
reviews: true # default true once mirror_objects is on
638+
labels: true # default true
639+
```
640+
641+
With `mirror_objects` on, **every** mirrored pull request gets a
642+
`branch_prefix` branch, including one whose head is already a branch of the
643+
source repository. A destination pull request has to name one head branch, and
644+
a uniform name is what keeps the mapping simple. With it off, the P1 rule
645+
stands: fork heads only.
646+
647+
**A merged source pull request is closed at the destination, not merged.** A
648+
merge performed at the destination would create a merge commit that differs
649+
from the source's, and the branch mirror then force overwrites it, so the
650+
destination would report a merge whose commit no longer exists. SyncerD closes
651+
the pull request instead and comments naming the source merge commit, which by
652+
then genuinely is on the destination's base branch.
653+
654+
**Review verdicts are mirrored as text, never as real approvals.** An approval
655+
posted by SyncerD's token would be a review nobody performed, and on a
656+
destination with required approvals it would satisfy a rule no human satisfied.
657+
658+
**Mentions and issue references are neutralised.** `@someone` in a mirrored
659+
body would otherwise notify whichever destination account owns that handle, and
660+
`#123` would cross link to an unrelated destination issue. Both are rewritten
661+
to render identically and link to nothing. Text inside fenced code blocks is
662+
left byte for byte.
663+
664+
Everything SyncerD writes carries a hidden marker. That is how a later run
665+
updates its own comments in place instead of posting duplicates, and how a
666+
destination pull request is re-identified after a lost state file, so losing
667+
state causes re-inspection rather than a second copy of everything.
668+
669+
Only comments SyncerD wrote are ever edited or deleted. A comment written by a
670+
person at the destination is left alone, even though the source is the
671+
authority: the source is authority over what it published, not over what
672+
somebody else said.
673+
674+
Today this needs GitHub on both ends. Any other destination is rejected at
675+
startup.
628676

629677
**Read this before enabling it.** A fork pull request head is code written by
630678
anyone who can open a pull request against the source. Turning this on pushes

internal/config/git.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,45 @@ type PullRequestsConfig struct {
124124
Enabled bool `mapstructure:"enabled"`
125125
BranchPrefix string `mapstructure:"branch_prefix"`
126126
States []string `mapstructure:"states"`
127+
128+
// MirrorObjects recreates each source pull request as a real pull
129+
// request at the destination. It also makes every mirrored pull
130+
// request get a branch under BranchPrefix, including one whose head
131+
// lives in the source repository, so a destination pull request always
132+
// has one uniform head name to point at.
133+
MirrorObjects bool `mapstructure:"mirror_objects"`
134+
135+
// The bool pointers distinguish unset from an explicit false, so the
136+
// defaults below apply only where the operator said nothing.
137+
Comments *bool `mapstructure:"comments"`
138+
Reviews *bool `mapstructure:"reviews"`
139+
Labels *bool `mapstructure:"labels"`
140+
}
141+
142+
// CommentsOrDefault reports whether discussion and review comments are
143+
// mirrored. On by default once objects are mirrored: a pull request with no
144+
// conversation is a poor mirror of one that has a conversation.
145+
func (p PullRequestsConfig) CommentsOrDefault() bool {
146+
if p.Comments == nil {
147+
return p.MirrorObjects
148+
}
149+
return *p.Comments
150+
}
151+
152+
// ReviewsOrDefault reports whether review verdicts are mirrored as text.
153+
func (p PullRequestsConfig) ReviewsOrDefault() bool {
154+
if p.Reviews == nil {
155+
return p.MirrorObjects
156+
}
157+
return *p.Reviews
158+
}
159+
160+
// LabelsOrDefault reports whether labels are applied at the destination.
161+
func (p PullRequestsConfig) LabelsOrDefault() bool {
162+
if p.Labels == nil {
163+
return true
164+
}
165+
return *p.Labels
127166
}
128167

129168
// BranchPrefixOrDefault reports the effective branch prefix.
@@ -321,6 +360,9 @@ func (c *Config) ValidateGitSync() error {
321360
// A disabled block is not validated. Its fields have no effect,
322361
// and rejecting a stale value in one would block a run that does
323362
// not read it.
363+
if m.PullRequests.MirrorObjects && !m.PullRequests.Enabled {
364+
return fmt.Errorf("git.mirrors[%d].pull_requests.mirror_objects is set but enabled is not; the destination pull request needs the head branch that enabled mirrors", i)
365+
}
324366
if m.PullRequests.Enabled {
325367
if err := vcs.ValidateBranchPrefix(m.PullRequests.BranchPrefixOrDefault()); err != nil {
326368
return fmt.Errorf("git.mirrors[%d].pull_requests: %w", i, err)

internal/config/git_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,34 @@ func TestBranchPrefixOrDefault(t *testing.T) {
363363
t.Errorf("BranchPrefixOrDefault = %q, want mirrored/pr", got)
364364
}
365365
}
366+
367+
func TestValidateGitSyncRejectsMirrorObjectsWithoutEnabled(t *testing.T) {
368+
cfg := &Config{Git: validGit()}
369+
cfg.Git.Mirrors[0].PullRequests = PullRequestsConfig{MirrorObjects: true}
370+
cfg.Git.ApplyDefaults()
371+
err := cfg.ValidateGitSync()
372+
if err == nil || !strings.Contains(err.Error(), "mirror_objects") {
373+
t.Fatalf("expected a mirror_objects error, got %v", err)
374+
}
375+
}
376+
377+
func TestPullRequestSwitchDefaults(t *testing.T) {
378+
off := PullRequestsConfig{Enabled: true}
379+
if off.CommentsOrDefault() || off.ReviewsOrDefault() {
380+
t.Error("comments and reviews follow mirror_objects, which is off here")
381+
}
382+
if !off.LabelsOrDefault() {
383+
t.Error("labels default on")
384+
}
385+
386+
on := PullRequestsConfig{Enabled: true, MirrorObjects: true}
387+
if !on.CommentsOrDefault() || !on.ReviewsOrDefault() {
388+
t.Error("comments and reviews default on once objects are mirrored")
389+
}
390+
391+
no := false
392+
explicit := PullRequestsConfig{Enabled: true, MirrorObjects: true, Comments: &no, Reviews: &no, Labels: &no}
393+
if explicit.CommentsOrDefault() || explicit.ReviewsOrDefault() || explicit.LabelsOrDefault() {
394+
t.Error("an explicit false must win over the default")
395+
}
396+
}

internal/gitsync/build.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,29 @@ func BuildMirrors(cfg *config.GitConfig) ([]Mirror, *Redactor, error) {
148148
}
149149
}
150150

151+
// Mirroring the objects needs a destination that can write them,
152+
// and, when the conversation is included, both sides able to read
153+
// and write comments. Refusing here beats a run that quietly
154+
// mirrors branches and calls it done.
155+
var destPRs vcs.PullRequestWriter
156+
var sourceConv, destConv vcs.PullRequestConversation
157+
if mc.PullRequests.MirrorObjects {
158+
destPRs, ok = dst.(vcs.PullRequestWriter)
159+
if !ok {
160+
return nil, 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())
161+
}
162+
if mc.PullRequests.CommentsOrDefault() || mc.PullRequests.ReviewsOrDefault() {
163+
sourceConv, ok = src.(vcs.PullRequestConversation)
164+
if !ok {
165+
return nil, 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())
166+
}
167+
destConv, ok = dst.(vcs.PullRequestConversation)
168+
if !ok {
169+
return nil, 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())
170+
}
171+
}
172+
}
173+
151174
mirrors = append(mirrors, Mirror{
152175
Name: mc.Name,
153176
Source: lister,
@@ -166,9 +189,16 @@ func BuildMirrors(cfg *config.GitConfig) ([]Mirror, *Redactor, error) {
166189
CreateMissing: mc.CreateMissingOrDefault(),
167190
Visibility: mc.Visibility,
168191
SourcePRs: sourcePRs,
192+
DestPRs: destPRs,
193+
SourceConv: sourceConv,
194+
DestConv: destConv,
169195
PullRequests: PRSyncConfig{
170-
Enabled: mc.PullRequests.Enabled,
171-
BranchPrefix: mc.PullRequests.BranchPrefixOrDefault(),
196+
Enabled: mc.PullRequests.Enabled,
197+
BranchPrefix: mc.PullRequests.BranchPrefixOrDefault(),
198+
MirrorObjects: mc.PullRequests.MirrorObjects,
199+
Comments: mc.PullRequests.CommentsOrDefault(),
200+
Reviews: mc.PullRequests.ReviewsOrDefault(),
201+
Labels: mc.PullRequests.LabelsOrDefault(),
172202
},
173203
})
174204
}

internal/gitsync/build_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,41 @@ func TestBuildMirrorsLeavesPullRequestsOffByDefault(t *testing.T) {
313313
t.Error("a mirror with no pull_requests block must not wire a lister")
314314
}
315315
}
316+
317+
func TestBuildMirrorsWiresPullRequestObjects(t *testing.T) {
318+
cfg := buildableConfig()
319+
// GitHub on both ends: the only pair that can write pull requests today.
320+
cfg.Mirrors[0].Destination = "gh2"
321+
cfg.Providers = append(cfg.Providers, config.GitProviderConfig{
322+
Name: "gh2", Type: "github", Owner: "mirror", Token: "ghp_token_value_two",
323+
})
324+
cfg.Mirrors[0].PullRequests = config.PullRequestsConfig{Enabled: true, MirrorObjects: true}
325+
326+
mirrors, _, err := BuildMirrors(cfg)
327+
if err != nil {
328+
t.Fatalf("build: %v", err)
329+
}
330+
m := mirrors[0]
331+
if !m.PullRequests.MirrorObjects {
332+
t.Error("MirrorObjects was not carried through")
333+
}
334+
if m.DestPRs == nil {
335+
t.Error("a GitHub destination must be wired as a pull request writer")
336+
}
337+
if m.SourceConv == nil || m.DestConv == nil {
338+
t.Error("conversation endpoints must be wired when comments default on")
339+
}
340+
if !m.PullRequests.Comments || !m.PullRequests.Reviews || !m.PullRequests.Labels {
341+
t.Errorf("switches = %+v", m.PullRequests)
342+
}
343+
}
344+
345+
func TestBuildMirrorsRejectsADestinationThatCannotWritePullRequests(t *testing.T) {
346+
cfg := buildableConfig()
347+
cfg.Mirrors[0].PullRequests = config.PullRequestsConfig{Enabled: true, MirrorObjects: true}
348+
349+
_, _, err := BuildMirrors(cfg)
350+
if err == nil || !strings.Contains(err.Error(), "mirror_objects") {
351+
t.Fatalf("a GitLab destination cannot write pull requests yet, want that error, got %v", err)
352+
}
353+
}

0 commit comments

Comments
 (0)