Skip to content

Commit a858c66

Browse files
authored
Check for branch duplicates after creating changeset specs (#448)
* Check for branch duplicates after creating changeset specs This is a follow-up to #442 and ensures that changeset specs are not getting silently lost by validating that multiple changeset specs in the same repository have different branches. I decided to make this a separate step _after_ the execution of the steps so that users can leverage the cache. That allows them to change the campaign spec and then rerun the command after they get this error, vs. the execution being aborted after running into this error (if we'd do the check inside executor). * Fix naming in duplicateBranchesErr
1 parent e16f3fa commit a858c66

File tree

5 files changed

+163
-15
lines changed

5 files changed

+163
-15
lines changed

cmd/src/campaigns_common.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ func campaignsExecute(ctx context.Context, out *output.Output, svc *campaigns.Se
272272
}()
273273
}
274274

275+
err = svc.ValidateChangesetSpecs(repos, specs)
276+
if err != nil {
277+
return "", "", err
278+
}
279+
275280
ids := make([]campaigns.ChangesetSpecID, len(specs))
276281

277282
if len(specs) > 0 {
@@ -366,7 +371,7 @@ func printExecutionError(out *output.Output, err error) {
366371
if err == context.Canceled {
367372
block.Writef("%sAborting", output.StyleBold)
368373
} else {
369-
block.Writef("%s%s=%+v)", output.StyleBold, e.Error())
374+
block.Writef("%s%s", output.StyleBold, e.Error())
370375
}
371376
}
372377
}

internal/campaigns/executor.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,9 @@ func (x *executor) do(ctx context.Context, task *Task) (err error) {
315315
})
316316

317317
// Add the spec to the executor's list of completed specs.
318-
x.specsMu.Lock()
319-
x.specs = append(x.specs, specs...)
320-
x.specsMu.Unlock()
318+
if err := x.addCompletedSpecs(task.Repository, specs); err != nil {
319+
return err
320+
}
321321

322322
return
323323
}
@@ -391,10 +391,10 @@ func (x *executor) do(ctx context.Context, task *Task) (err error) {
391391
status.ChangesetSpecs = specs
392392
})
393393

394-
// Add the spec to the executor's list of completed specs.
395-
x.specsMu.Lock()
396-
x.specs = append(x.specs, specs...)
397-
x.specsMu.Unlock()
394+
if err := x.addCompletedSpecs(task.Repository, specs); err != nil {
395+
return err
396+
}
397+
398398
return
399399
}
400400

@@ -408,6 +408,14 @@ func (x *executor) updateTaskStatus(task *Task, update func(status *TaskStatus))
408408
}
409409
}
410410

411+
func (x *executor) addCompletedSpecs(repository *graphql.Repository, specs []*ChangesetSpec) error {
412+
x.specsMu.Lock()
413+
defer x.specsMu.Unlock()
414+
415+
x.specs = append(x.specs, specs...)
416+
return nil
417+
}
418+
411419
func (x *executor) LockedTaskStatuses(callback func([]*TaskStatus)) {
412420
x.statusesMu.RLock()
413421
defer x.statusesMu.RUnlock()

internal/campaigns/executor_test.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -401,13 +401,18 @@ repository_name=github.com/sourcegraph/src-cli`,
401401

402402
executor.Start(context.Background())
403403
specs, err := executor.Wait(context.Background())
404-
if tc.wantErrInclude == "" && err != nil {
405-
t.Fatalf("execution failed: %s", err)
406-
}
407-
if err != nil && !strings.Contains(err.Error(), tc.wantErrInclude) {
408-
t.Errorf("wrong error. have=%q want included=%q", err, tc.wantErrInclude)
409-
}
410-
if tc.wantErrInclude != "" {
404+
if tc.wantErrInclude == "" {
405+
if err != nil {
406+
t.Fatalf("execution failed: %s", err)
407+
}
408+
} else {
409+
if err == nil {
410+
t.Fatalf("expected error to include %q, but got no error", tc.wantErrInclude)
411+
} else {
412+
if !strings.Contains(err.Error(), tc.wantErrInclude) {
413+
t.Errorf("wrong error. have=%q want included=%q", err, tc.wantErrInclude)
414+
}
415+
}
411416
return
412417
}
413418

internal/campaigns/service.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,66 @@ func (svc *Service) ExecuteCampaignSpec(ctx context.Context, opts ExecutorOpts,
415415
return specs, x.LogFiles(), errs.ErrorOrNil()
416416
}
417417

418+
func (svc *Service) ValidateChangesetSpecs(repos []*graphql.Repository, specs []*ChangesetSpec) error {
419+
repoByID := make(map[string]*graphql.Repository, len(repos))
420+
for _, repo := range repos {
421+
repoByID[repo.ID] = repo
422+
}
423+
424+
byRepoAndBranch := make(map[string]map[string][]*ChangesetSpec)
425+
for _, spec := range specs {
426+
_, ok := byRepoAndBranch[spec.HeadRepository]
427+
if !ok {
428+
byRepoAndBranch[spec.HeadRepository] = make(map[string][]*ChangesetSpec)
429+
}
430+
431+
byRepoAndBranch[spec.HeadRepository][spec.HeadRef] = append(byRepoAndBranch[spec.HeadRepository][spec.HeadRef], spec)
432+
}
433+
434+
duplicates := make(map[*graphql.Repository]map[string]int)
435+
for repoID, specsByBranch := range byRepoAndBranch {
436+
for branch, specs := range specsByBranch {
437+
if len(specs) < 2 {
438+
continue
439+
}
440+
441+
r := repoByID[repoID]
442+
if _, ok := duplicates[r]; !ok {
443+
duplicates[r] = make(map[string]int)
444+
}
445+
446+
duplicates[r][branch] = len(specs)
447+
}
448+
}
449+
450+
if len(duplicates) > 0 {
451+
return &duplicateBranchesErr{duplicates: duplicates}
452+
}
453+
454+
return nil
455+
}
456+
457+
type duplicateBranchesErr struct {
458+
duplicates map[*graphql.Repository]map[string]int
459+
}
460+
461+
func (e *duplicateBranchesErr) Error() string {
462+
var out strings.Builder
463+
464+
fmt.Fprintf(&out, "Multiple changeset specs have the same branch:\n\n")
465+
466+
for repo, branches := range e.duplicates {
467+
for branch, duplicates := range branches {
468+
branch = strings.TrimPrefix(branch, "refs/heads/")
469+
fmt.Fprintf(&out, "\t* %s: %d changeset specs have the branch %q\n", repo.Name, duplicates, branch)
470+
}
471+
}
472+
473+
fmt.Fprint(&out, "\nMake sure that the changesetTemplate.branch field in the campaign spec produces unique values for each changeset in a single repository and rerun this command.")
474+
475+
return out.String()
476+
}
477+
418478
func (svc *Service) ParseCampaignSpec(in io.Reader) (*CampaignSpec, string, error) {
419479
data, err := ioutil.ReadAll(in)
420480
if err != nil {

internal/campaigns/service_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/http"
99
"net/http/httptest"
10+
"strings"
1011
"testing"
1112

1213
"github.com/google/go-cmp/cmp"
@@ -455,3 +456,72 @@ func mockDirectoriesInReposResults(t *testing.T, filesPerRepo filesInRepos) (cli
455456

456457
return mockGraphQLClient(string(rawRes))
457458
}
459+
460+
func TestService_ValidateChangesetSpecs(t *testing.T) {
461+
repo1 := &graphql.Repository{ID: "repo-graphql-id-1", Name: "github.com/sourcegraph/src-cli"}
462+
repo2 := &graphql.Repository{ID: "repo-graphql-id-2", Name: "github.com/sourcegraph/sourcegraph"}
463+
464+
tests := map[string]struct {
465+
repos []*graphql.Repository
466+
specs []*ChangesetSpec
467+
468+
wantErrInclude string
469+
}{
470+
"no errors": {
471+
repos: []*graphql.Repository{repo1, repo2},
472+
specs: []*ChangesetSpec{
473+
{CreatedChangeset: &CreatedChangeset{
474+
HeadRepository: repo1.ID, HeadRef: "refs/heads/branch-1"},
475+
},
476+
{CreatedChangeset: &CreatedChangeset{
477+
HeadRepository: repo1.ID, HeadRef: "refs/heads/branch-2"},
478+
},
479+
{CreatedChangeset: &CreatedChangeset{
480+
HeadRepository: repo2.ID, HeadRef: "refs/heads/branch-1"},
481+
},
482+
{CreatedChangeset: &CreatedChangeset{
483+
HeadRepository: repo2.ID, HeadRef: "refs/heads/branch-2"},
484+
},
485+
},
486+
},
487+
488+
"duplicate branches": {
489+
repos: []*graphql.Repository{repo1, repo2},
490+
specs: []*ChangesetSpec{
491+
{CreatedChangeset: &CreatedChangeset{
492+
HeadRepository: repo1.ID, HeadRef: "refs/heads/branch-1"},
493+
},
494+
{CreatedChangeset: &CreatedChangeset{
495+
HeadRepository: repo1.ID, HeadRef: "refs/heads/branch-2"},
496+
},
497+
{CreatedChangeset: &CreatedChangeset{
498+
HeadRepository: repo2.ID, HeadRef: "refs/heads/branch-1"},
499+
},
500+
{CreatedChangeset: &CreatedChangeset{
501+
HeadRepository: repo2.ID, HeadRef: "refs/heads/branch-1"},
502+
},
503+
},
504+
wantErrInclude: `github.com/sourcegraph/sourcegraph: 2 changeset specs have the branch "branch-1"`,
505+
},
506+
}
507+
508+
for name, tt := range tests {
509+
t.Run(name, func(t *testing.T) {
510+
svc := &Service{}
511+
haveErr := svc.ValidateChangesetSpecs(tt.repos, tt.specs)
512+
if tt.wantErrInclude != "" {
513+
if haveErr == nil {
514+
t.Fatalf("expected %q to be included in error, but got none", tt.wantErrInclude)
515+
} else {
516+
if !strings.Contains(haveErr.Error(), tt.wantErrInclude) {
517+
t.Fatalf("expected %q to be included in error, but was not. error=%q", tt.wantErrInclude, haveErr.Error())
518+
}
519+
}
520+
} else {
521+
if haveErr != nil {
522+
t.Fatalf("unexpected error: %s", haveErr)
523+
}
524+
}
525+
})
526+
}
527+
}

0 commit comments

Comments
 (0)