Skip to content

Commit 8eb4741

Browse files
committed
fix(github): send an empty label array rather than null
Once the mirror owns labels, a source pull request with none produced a nil slice, which marshals to null, and GitHub answers 422: the field has to be an array. Clearing labels is a legitimate and common request, so this broke the update of nearly every mirrored pull request, and the label failure was wrapped in a way that failed the whole pull request rather than just its labels. Found by the live suite on its first real run against GitHub, in the first minute it existed. No fake had an opinion about null.
1 parent 6c31b30 commit 8eb4741

4 files changed

Lines changed: 59 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- A failed state save on the fail-fast path was silent, so everything copied before the abort was copied again on the next run with no explanation
2222
- `git-sync` could not authenticate to Azure DevOps over git in `pat` mode, fixed in v0.2.1 and described there
2323

24+
### Fixed
25+
- A mirrored pull request with no labels failed to update, taking the whole pull request with it. Once the mirror owned labels, an empty set was sent as null rather than as an empty array, and GitHub rejects that with 422. Most pull requests carry no labels, so this affected most of them. Found by the live suite on its first real run
26+
2427
### Added
2528
- A live provider test suite behind the `live` build tag, run with `make test-live`. It creates two throwaway repositories against a real GitHub account, mirrors a pull request between them, and checks what actually landed, then deletes them. Every other test in the repository checks SyncerD against fakes written alongside the code, which is how three defects that made pull request mirroring inoperable passed a green suite and a review
2629

internal/livetest/live_test.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ func openPullRequest(t *testing.T, p *github.Provider, repoPath string) int {
154154
return pr.Number
155155
}
156156

157+
// relist reads the source pull requests again, as each run does, and
158+
// asserts how many are open.
159+
func relist(t *testing.T, p *github.Provider, repoPath string, want int) []vcs.PullRequest {
160+
t.Helper()
161+
prs, err := p.ListPullRequests(context.Background(), repoPath, vcs.PRListOptions{})
162+
if err != nil {
163+
t.Fatalf("list pull requests: %v", err)
164+
}
165+
if len(prs) != want {
166+
t.Fatalf("got %d open pull requests, want %d", len(prs), want)
167+
}
168+
return prs
169+
}
170+
157171
// TestGitHubToGitHubPullRequestRoundTrip mirrors a repository and its open
158172
// pull request to a second repository, against the real API.
159173
//
@@ -180,13 +194,7 @@ func TestGitHubToGitHubPullRequestRoundTrip(t *testing.T) {
180194
t.Logf("source pull request %s#%d", srcPath, number)
181195

182196
// Read it back the way a run would.
183-
prs, err := p.ListPullRequests(ctx, srcPath, vcs.PRListOptions{})
184-
if err != nil {
185-
t.Fatalf("list pull requests: %v", err)
186-
}
187-
if len(prs) != 1 {
188-
t.Fatalf("got %d open pull requests, want 1", len(prs))
189-
}
197+
prs := relist(t, p, srcPath, 1)
190198

191199
// Mirror the branches first: a destination pull request cannot
192200
// reference commits that have not arrived.
@@ -248,6 +256,7 @@ func TestGitHubToGitHubPullRequestRoundTrip(t *testing.T) {
248256
}
249257

250258
// A second run must change nothing.
259+
prs = relist(t, p, srcPath, 1)
251260
res, err = prsync.Sync(ctx, prs, prsync.Options{
252261
Mirror: "live", SourceRepo: srcPath, DestRepo: p.QualifiedPath(dstName),
253262
BranchPrefix: "syncerd/pr", Source: p, Dest: p, SourceConv: p, DestConv: p,
@@ -260,14 +269,19 @@ func TestGitHubToGitHubPullRequestRoundTrip(t *testing.T) {
260269
t.Errorf("a second run created %d pull requests; it must create none", res.Created)
261270
}
262271

263-
// A comment at the source must reach the destination, and survive an
264-
// edit without being duplicated.
272+
// A comment at the source must reach the destination, exactly once.
265273
commentID, err := p.CreateComment(ctx, srcPath, number, "a comment from the live test")
266274
if err != nil {
267275
t.Fatalf("comment at the source: %v", err)
268276
}
269277
t.Logf("source comment %s", commentID)
270278

279+
// Re-list, the way a run does. The engine reads the source afresh every
280+
// time, and the watermark deliberately skips a pull request whose
281+
// reported timestamp has not moved: reusing the list captured before
282+
// the comment would test a situation that never occurs.
283+
prs = relist(t, p, srcPath, 1)
284+
271285
res, err = prsync.Sync(ctx, prs, prsync.Options{
272286
Mirror: "live", SourceRepo: srcPath, DestRepo: p.QualifiedPath(dstName),
273287
BranchPrefix: "syncerd/pr", Source: p, Dest: p, SourceConv: p, DestConv: p,

internal/vcs/github/prwrite.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ func (p *Provider) setState(ctx context.Context, repoPath string, number int, st
134134
// that does not exist yet as a side effect of this call, so no separate
135135
// creation step is needed.
136136
func (p *Provider) setLabels(ctx context.Context, repoPath string, number int, labels []string) error {
137+
// A nil slice marshals to null, and GitHub answers 422 for it: the
138+
// field must be an array. Clearing the labels is a legitimate request,
139+
// and it is the common one, since most pull requests carry none.
140+
if labels == nil {
141+
labels = []string{}
142+
}
137143
payload := map[string]any{"labels": labels}
138144
_, _, err := p.do(ctx, http.MethodPut,
139145
fmt.Sprintf("%s/repos/%s/issues/%d/labels", p.apiURL, repoPath, number), payload)

internal/vcs/github/prwrite_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,30 @@ func TestReopenPullRequest(t *testing.T) {
181181
t.Errorf("state = %v, want open", payload["state"])
182182
}
183183
}
184+
185+
func TestClearingLabelsSendsAnEmptyArrayNotNull(t *testing.T) {
186+
// A source pull request with no labels is the common case. A nil slice
187+
// marshals to null, which GitHub rejects with 422, and that failed the
188+
// whole pull request rather than just its labels.
189+
var raw map[string]json.RawMessage
190+
191+
mux := http.NewServeMux()
192+
mux.HandleFunc("/repos/acme/widget/pulls/12", func(w http.ResponseWriter, r *http.Request) {
193+
writeJSON(w, map[string]any{"number": 12})
194+
})
195+
mux.HandleFunc("/repos/acme/widget/issues/12/labels", func(w http.ResponseWriter, r *http.Request) {
196+
_ = json.NewDecoder(r.Body).Decode(&raw)
197+
writeJSON(w, []map[string]any{})
198+
})
199+
200+
p, _ := newProvider(t, mux)
201+
err := p.UpdatePullRequest(context.Background(), "acme/widget", 12, vcs.PullRequestSpec{
202+
Title: "Add login", BaseBranch: "main", SyncLabels: true, Labels: nil,
203+
})
204+
if err != nil {
205+
t.Fatalf("update: %v", err)
206+
}
207+
if got := string(raw["labels"]); got != "[]" {
208+
t.Errorf("labels = %s, want an empty array", got)
209+
}
210+
}

0 commit comments

Comments
 (0)