diff --git a/README.md b/README.md index 410fa35..d84b7da 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ This action checks for available dependency updates to a go project, and opens individual pull requests proposing each available update. * Ignores dependencies not released with semver -* Ignores dependencies if the initial PR is closed * Go module major version updates (e.g. `github.com/foo/bar/v2`) * Vendoring detection and support * Can multiple multiple base branches +* Update batching Suggested triggers: `schedule`, `workflow_dispatch`. diff --git a/action.yml b/action.yml index 6c26e4e..5a6e96d 100644 --- a/action.yml +++ b/action.yml @@ -18,19 +18,28 @@ inputs: log_level: description: 'Control debug/info/warn/error output' required: false + batches: + description: > + Configuration for grouping updates together, newline separated following format: + label:prefix1,prefix2 + e.g. + internal:github.com/thepwagner/ + aws:github.com/aws + required: false runs: using: "composite" steps: - - name: Verify Go SDK - run: which go || echo "Go required, please use actions/setup-go before me" - shell: bash - - name: Compile action-update-go - run: cd "${{github.action_path}}" && go build -o ./action-update-go . - shell: bash - - name: Run action-update-go - run: pwd && "${{github.action_path}}/action-update-go" - shell: bash - env: - INPUT_BRANCHES: ${{ inputs.branches }} - INPUT_TOKEN: ${{ inputs.token }} - INPUT_LOG_LEVEL: ${{ inputs.log_level }} + - name: Verify Go SDK + run: which go || echo "Go required, please use actions/setup-go before me" + shell: bash + - name: Compile action-update-go + run: cd "${{github.action_path}}" && go build -o ./action-update-go . + shell: bash + - name: Run action-update-go + run: pwd && "${{github.action_path}}/action-update-go" + shell: bash + env: + INPUT_BRANCHES: ${{ inputs.branches }} + INPUT_BATCHES: ${{ inputs.batches }} + INPUT_TOKEN: ${{ inputs.token }} + INPUT_LOG_LEVEL: ${{ inputs.log_level }} diff --git a/cmd/env.go b/cmd/env.go index 2ba3fd5..a55eeb1 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -8,6 +8,7 @@ import ( "github.com/caarlos0/env/v5" "github.com/google/go-github/v32/github" "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" ) type Environment struct { @@ -15,6 +16,7 @@ type Environment struct { GitHubEventPath string `env:"GITHUB_EVENT_PATH"` GitHubRepository string `env:"GITHUB_REPOSITORY"` + InputBatches string `env:"INPUT_BATCHES"` InputBranches string `env:"INPUT_BRANCHES"` GitHubToken string `env:"INPUT_TOKEN"` InputLogLevel string `env:"INPUT_LOG_LEVEL" envDefault:"debug"` @@ -56,6 +58,28 @@ func (e *Environment) Branches() (branches []string) { return } +func (e *Environment) Batches() (map[string][]string, error) { + raw := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(e.InputBatches), &raw); err != nil { + return nil, fmt.Errorf("decoding batches yaml: %w", err) + } + + m := make(map[string][]string, len(raw)) + for key, value := range raw { + var prefixes []string + switch v := value.(type) { + case []interface{}: + for _, s := range v { + prefixes = append(prefixes, fmt.Sprintf("%v", s)) + } + case string: + prefixes = append(prefixes, v) + } + m[key] = prefixes + } + return m, nil +} + func (e *Environment) LogLevel() logrus.Level { if e.InputLogLevel == "" { return logrus.InfoLevel diff --git a/cmd/env_test.go b/cmd/env_test.go index f19ec8c..ff1dcc7 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -6,6 +6,7 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEnvironment_LogLevel(t *testing.T) { @@ -22,3 +23,43 @@ func TestEnvironment_LogLevel(t *testing.T) { }) } } + +func TestEnvironment_Batches(t *testing.T) { + cases := []struct { + input string + batches map[string][]string + }{ + { + input: `foo: [bar, baz]`, + batches: map[string][]string{"foo": {"bar", "baz"}}, + }, + { + input: `--- +foo: bar +foz: baz`, + batches: map[string][]string{ + "foo": {"bar"}, + "foz": {"baz"}, + }, + }, + { + input: `foo: +- bar +- baz`, + batches: map[string][]string{ + "foo": {"bar", "baz"}, + }, + }, + { + input: "", + batches: map[string][]string{}, + }, + } + + for _, tc := range cases { + e := Environment{InputBatches: tc.input} + b, err := e.Batches() + require.NoError(t, err) + assert.Equal(t, tc.batches, b) + } +} diff --git a/go.mod b/go.mod index f06cf7e..10502f6 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/stretchr/testify v1.6.0 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c ) replace github.com/containerd/containerd => github.com/containerd/containerd v1.4.0 diff --git a/handler/pullrequest.go b/handler/pullrequest.go index da91cf8..cf397f9 100644 --- a/handler/pullrequest.go +++ b/handler/pullrequest.go @@ -28,7 +28,7 @@ func PullRequest(ctx context.Context, env *cmd.Environment, evt interface{}) err } func prReopened(ctx context.Context, env *cmd.Environment, pr *github.PullRequestEvent) error { - repo, updater, err := getRepoUpdater(env) + _, updater, err := getRepoUpdater(env) if err != nil { return err } @@ -36,7 +36,7 @@ func prReopened(ctx context.Context, env *cmd.Environment, pr *github.PullReques prRef := pr.GetPullRequest().GetHead().GetRef() logrus.WithField("ref", prRef).Info("PR reopened, recreating update") - base, update := repo.Parse(prRef) + base, update := updater.Parse(prRef) if update == nil { logrus.Info("not an update PR") return nil diff --git a/handler/schedule.go b/handler/schedule.go index a260ba3..178cdd9 100644 --- a/handler/schedule.go +++ b/handler/schedule.go @@ -2,6 +2,7 @@ package handler import ( "context" + "fmt" "github.com/go-git/go-git/v5" "github.com/sirupsen/logrus" @@ -62,6 +63,10 @@ func getRepoUpdater(env *cmd.Environment) (updater.Repo, *updater.RepoUpdater, e } gomodUpdater := gomod.NewUpdater(modRepo.Root()) - repoUpdater := updater.NewRepoUpdater(modRepo, gomodUpdater) + batches, err := env.Batches() + if err != nil { + return nil, nil, fmt.Errorf("parsing batches") + } + repoUpdater := updater.NewRepoUpdater(modRepo, gomodUpdater, updater.WithBatches(batches)) return gitRepo, repoUpdater, nil } diff --git a/repo/git.go b/repo/git.go index 9362390..b0041d5 100644 --- a/repo/git.go +++ b/repo/git.go @@ -23,8 +23,6 @@ type GitRepo struct { branch string author GitIdentity remotes bool - - branchNamer UpdateBranchNamer } var _ updater.Repo = (*GitRepo)(nil) @@ -67,12 +65,11 @@ func NewGitRepo(repo *git.Repository) (*GitRepo, error) { branch = head.Name().Short() } return &GitRepo{ - repo: repo, - wt: wt, - branch: branch, - remotes: len(remotes) > 0, - author: DefaultGitIdentity, - branchNamer: DefaultUpdateBranchNamer{}, + repo: repo, + wt: wt, + branch: branch, + remotes: len(remotes) > 0, + author: DefaultGitIdentity, }, nil } @@ -122,22 +119,21 @@ func (t *GitRepo) setBranch(refName plumbing.ReferenceName) error { return nil } -func (t *GitRepo) NewBranch(baseBranch string, update updater.Update) error { - branch := t.branchNamer.Format(baseBranch, update) +func (t *GitRepo) NewBranch(base, branch string) error { log := logrus.WithFields(logrus.Fields{ - "base": baseBranch, + "base": base, "branch": branch, }) log.Debug("creating branch") // Map string to a ref: - baseRef, err := t.repo.Reference(plumbing.NewBranchReferenceName(baseBranch), true) + baseRef, err := t.repo.Reference(plumbing.NewBranchReferenceName(base), true) if err != nil { if err != plumbing.ErrReferenceNotFound { return fmt.Errorf("querying branch ref: %w", err) } log.Debug("not found locally, checking remote") - remoteRef, err := t.repo.Reference(plumbing.NewRemoteReferenceName(RemoteName, baseBranch), true) + remoteRef, err := t.repo.Reference(plumbing.NewRemoteReferenceName(RemoteName, base), true) if err != nil { return fmt.Errorf("querying remote branch ref: %w", err) } @@ -238,27 +234,3 @@ func (t *GitRepo) push(ctx context.Context) error { logrus.Debug("pushed to remote") return nil } - -func (t *GitRepo) Updates(_ context.Context) (updater.UpdatesByBranch, error) { - branches, err := t.repo.Branches() - if err != nil { - return nil, fmt.Errorf("iterating branches: %w", err) - } - defer branches.Close() - - ret := updater.UpdatesByBranch{} - addToIndex := func(ref *plumbing.Reference) error { - if base, update := t.branchNamer.Parse(ref.Name().Short()); update != nil { - ret.AddOpen(base, *update) - } - return nil - } - if err := branches.ForEach(addToIndex); err != nil { - return nil, fmt.Errorf("indexing branches: %w", err) - } - return ret, nil -} - -func (t *GitRepo) Parse(b string) (string, *updater.Update) { - return t.branchNamer.Parse(b) -} diff --git a/repo/git_test.go b/repo/git_test.go index 091a5d7..bd90b46 100644 --- a/repo/git_test.go +++ b/repo/git_test.go @@ -22,13 +22,7 @@ const ( updateBranch = "action-update-go/main/github.com/foo/bar/v1.0.0" ) -var ( - fileData = []byte{1, 2, 3, 4} - fakeUpdate = updater.Update{ - Path: "github.com/foo/bar", - Next: "v1.0.0", - } -) +var fileData = []byte{1, 2, 3, 4} func TestNewGitRepo(t *testing.T) { gr := initGitRepo(t, plumbing.NewBranchReferenceName(branchName)) @@ -79,21 +73,21 @@ func TestGitRepo_SetBranch_NotFound(t *testing.T) { func TestGitRepo_NewBranch(t *testing.T) { gr := initGitRepo(t, plumbing.NewBranchReferenceName(branchName)) - err := gr.NewBranch(branchName, fakeUpdate) + err := gr.NewBranch(branchName, updateBranch) assert.NoError(t, err) assert.Equal(t, updateBranch, gr.Branch()) } func TestGitRepo_NewBranch_FromRemote(t *testing.T) { gr := initGitRepo(t, plumbing.NewRemoteReferenceName(repo.RemoteName, branchName)) - err := gr.NewBranch(branchName, fakeUpdate) + err := gr.NewBranch(branchName, updateBranch) assert.NoError(t, err) assert.Equal(t, updateBranch, gr.Branch()) } func TestGitRepo_Push(t *testing.T) { gr := initGitRepo(t, plumbing.NewRemoteReferenceName(repo.RemoteName, branchName)) - err := gr.NewBranch(branchName, fakeUpdate) + err := gr.NewBranch(branchName, updateBranch) require.NoError(t, err) tmpFile := addTempFile(t, gr) @@ -144,7 +138,7 @@ func TestGitRepo_Push_WithRemote(t *testing.T) { gr, err := repo.NewGitRepo(downstream) require.NoError(t, err) - err = gr.NewBranch(branchName, fakeUpdate) + err = gr.NewBranch(branchName, updateBranch) require.NoError(t, err) addTempFile(t, gr) diff --git a/repo/github.go b/repo/github.go index 179ba02..aa9bd50 100644 --- a/repo/github.go +++ b/repo/github.go @@ -50,13 +50,10 @@ func NewGitHubClient(token string) *github.Client { return ghClient } -func (g *GitHubRepo) Root() string { return g.repo.Root() } -func (g *GitHubRepo) Branch() string { return g.repo.Branch() } -func (g *GitHubRepo) SetBranch(branch string) error { return g.repo.SetBranch(branch) } -func (g *GitHubRepo) Parse(branch string) (string, *updater.Update) { return g.repo.Parse(branch) } -func (g *GitHubRepo) NewBranch(baseBranch string, update updater.Update) error { - return g.repo.NewBranch(baseBranch, update) -} +func (g *GitHubRepo) Root() string { return g.repo.Root() } +func (g *GitHubRepo) Branch() string { return g.repo.Branch() } +func (g *GitHubRepo) SetBranch(branch string) error { return g.repo.SetBranch(branch) } +func (g *GitHubRepo) NewBranch(base, branch string) error { return g.repo.NewBranch(base, branch) } // Push follows the git push with opening a pull request func (g *GitHubRepo) Push(ctx context.Context, update updater.Update) error { @@ -69,52 +66,6 @@ func (g *GitHubRepo) Push(ctx context.Context, update updater.Update) error { return nil } -// Updates amends with branches found in open PRs -func (g *GitHubRepo) Updates(ctx context.Context) (updater.UpdatesByBranch, error) { - updates, err := g.repo.Updates(ctx) - if err != nil { - return nil, err - } - - if err := g.addUpdatesFromPR(ctx, updates); err != nil { - return nil, err - } - - return updates, nil -} - -func (g *GitHubRepo) addUpdatesFromPR(ctx context.Context, updates updater.UpdatesByBranch) error { - prList, _, err := g.github.PullRequests.List(ctx, g.owner, g.repoName, &github.PullRequestListOptions{ - State: "all", - }) - if err != nil { - return fmt.Errorf("listing pull requests: %w", err) - } - - for _, pr := range prList { - base, update := g.repo.Parse(*pr.Head.Ref) - if update == nil { - continue - } - - logrus.WithFields(logrus.Fields{ - "base_branch": base, - "path": update.Path, - "version": update.Next, - }).Debug("found existing PR") - - switch pr.GetState() { - case "open": - updates.AddOpen(base, *update) - case "closed": - if !pr.GetMerged() { - updates.AddClosed(base, *update) - } - } - } - return nil -} - func (g *GitHubRepo) createPR(ctx context.Context, update updater.Update) error { title, body, err := g.prContent.Generate(ctx, update) if err != nil { @@ -122,7 +73,7 @@ func (g *GitHubRepo) createPR(ctx context.Context, update updater.Update) error } branch := g.repo.Branch() - baseBranch, _ := g.repo.Parse(branch) + baseBranch := strings.Split(branch, "/")[1] pullRequest, _, err := g.github.PullRequests.Create(ctx, g.owner, g.repoName, &github.NewPullRequest{ Title: &title, Body: &body, diff --git a/repo/github_test.go b/repo/github_test.go index 4735fcf..99e5486 100644 --- a/repo/github_test.go +++ b/repo/github_test.go @@ -1,7 +1,6 @@ package repo_test import ( - "context" "os" "testing" @@ -19,22 +18,6 @@ func TestNewGitHubRepo(t *testing.T) { assert.NotNil(t, gh) } -func TestGitHubRepo_Updates(t *testing.T) { - token := tokenOrSkip(t) - - gr := initGitRepo(t, plumbing.NewBranchReferenceName(branchName)) - gh, err := repo.NewGitHubRepo(gr, "thepwagner/action-update-go", token) - require.NoError(t, err) - - updates, err := gh.Updates(context.Background()) - require.NoError(t, err) - assert.Len(t, updates, 3) - - mainUpdates := updates["main"] - assert.Len(t, mainUpdates.Open, 0) - assert.Len(t, mainUpdates.Closed, 4) -} - func tokenOrSkip(t *testing.T) string { token := os.Getenv("GITHUB_TOKEN") if token == "" { diff --git a/updater/batch.go b/updater/batch.go new file mode 100644 index 0000000..31ad172 --- /dev/null +++ b/updater/batch.go @@ -0,0 +1,38 @@ +package updater + +import ( + "sort" + "strings" +) + +func GroupDependencies(batches map[string][]string, deps []Dependency) map[string][]Dependency { + // Build an inverted index of prefixes, and list of all prefixes sorted by length: + index := map[string]string{} + var prefixes []string + for name, prefixList := range batches { + prefixes = append(prefixes, prefixList...) + for _, p := range prefixList { + index[p] = name + } + } + sort.Slice(prefixes, func(i, j int) bool { + return len(prefixes[i]) > len(prefixes[j]) + }) + + // Sort dependencies into groups: + groups := make(map[string][]Dependency, len(batches)) + for _, dep := range deps { + // Find the closest prefix, there may not be one: + var branchName string + for _, p := range prefixes { + if strings.HasPrefix(dep.Path, p) { + branchName = index[p] + break + } + } + + groups[branchName] = append(groups[branchName], dep) + } + + return groups +} diff --git a/updater/batch_test.go b/updater/batch_test.go new file mode 100644 index 0000000..40a44f2 --- /dev/null +++ b/updater/batch_test.go @@ -0,0 +1,31 @@ +package updater_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thepwagner/action-update-go/updater" +) + +func TestGroupDependencies_NoBatch(t *testing.T) { + dep := updater.Dependency{Path: mockPath} + b := updater.GroupDependencies(nil, []updater.Dependency{dep}) + + assert.Equal(t, map[string][]updater.Dependency{ + "": {dep}, + }, b) +} + +func TestGroupDependencies_Batch(t *testing.T) { + dep := updater.Dependency{Path: mockPath} + b := updater.GroupDependencies( + map[string][]string{ + "not-awesome": {"prefix-does-not-match"}, + "semi-awesome": {"github.com/"}, // match, but there is a longer prefix + "awesome": {"github.com/foo"}, + }, []updater.Dependency{dep}) + + assert.Equal(t, map[string][]updater.Dependency{ + "awesome": {dep}, + }, b) +} diff --git a/repo/branch.go b/updater/branch.go similarity index 64% rename from repo/branch.go rename to updater/branch.go index d97635a..39eb79b 100644 --- a/repo/branch.go +++ b/updater/branch.go @@ -1,35 +1,38 @@ -package repo +package updater import ( "path" "strings" - - "github.com/thepwagner/action-update-go/updater" ) const branchPrefix = "action-update-go" // UpdateBranchNamer names branches for proposed updates. type UpdateBranchNamer interface { - Format(baseBranch string, update updater.Update) string - Parse(string) (baseBranch string, update *updater.Update) + Format(baseBranch string, update Update) string + FormatBatch(baseBranch, batchName string) string + Parse(string) (baseBranch string, update *Update) } type DefaultUpdateBranchNamer struct{} var _ UpdateBranchNamer = (*DefaultUpdateBranchNamer)(nil) -func (d DefaultUpdateBranchNamer) Format(baseBranch string, update updater.Update) string { +func (d DefaultUpdateBranchNamer) Format(baseBranch string, update Update) string { return path.Join(branchPrefix, baseBranch, update.Path, update.Next) } -func (d DefaultUpdateBranchNamer) Parse(branch string) (baseBranch string, u *updater.Update) { +func (d DefaultUpdateBranchNamer) FormatBatch(baseBranch, batchName string) string { + return path.Join(branchPrefix, baseBranch, batchName) +} + +func (d DefaultUpdateBranchNamer) Parse(branch string) (baseBranch string, u *Update) { branchSplit := strings.Split(branch, "/") if len(branchSplit) < 4 || branchSplit[0] != branchPrefix { return "", nil } versPos := len(branchSplit) - 1 - return branchSplit[1], &updater.Update{ + return branchSplit[1], &Update{ Path: path.Join(branchSplit[2:versPos]...), Next: branchSplit[versPos], } diff --git a/repo/branch_test.go b/updater/branch_test.go similarity index 89% rename from repo/branch_test.go rename to updater/branch_test.go index 0ec7443..76e3783 100644 --- a/repo/branch_test.go +++ b/updater/branch_test.go @@ -1,17 +1,16 @@ -package repo_test +package updater_test import ( "fmt" "testing" "github.com/stretchr/testify/assert" - "github.com/thepwagner/action-update-go/repo" "github.com/thepwagner/action-update-go/updater" ) -func TestDefaultUpdateBranchName(t *testing.T) { +func TestDefaultUpdateBranchNamer(t *testing.T) { const baseBranch = "main" - branchNamer := repo.DefaultUpdateBranchNamer{} + branchNamer := updater.DefaultUpdateBranchNamer{} cases := []struct { branch string diff --git a/updater/mockrepo_test.go b/updater/mockrepo_test.go index 72465d2..b8c617c 100644 --- a/updater/mockrepo_test.go +++ b/updater/mockrepo_test.go @@ -28,13 +28,13 @@ func (_m *mockRepo) Branch() string { return r0 } -// NewBranch provides a mock function with given fields: baseBranch, update -func (_m *mockRepo) NewBranch(baseBranch string, update updater.Update) error { - ret := _m.Called(baseBranch, update) +// NewBranch provides a mock function with given fields: base, branch +func (_m *mockRepo) NewBranch(base string, branch string) error { + ret := _m.Called(base, branch) var r0 error - if rf, ok := ret.Get(0).(func(string, updater.Update) error); ok { - r0 = rf(baseBranch, update) + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(base, branch) } else { r0 = ret.Error(0) } @@ -42,29 +42,6 @@ func (_m *mockRepo) NewBranch(baseBranch string, update updater.Update) error { return r0 } -// Parse provides a mock function with given fields: _a0 -func (_m *mockRepo) Parse(_a0 string) (string, *updater.Update) { - ret := _m.Called(_a0) - - var r0 string - if rf, ok := ret.Get(0).(func(string) string); ok { - r0 = rf(_a0) - } else { - r0 = ret.Get(0).(string) - } - - var r1 *updater.Update - if rf, ok := ret.Get(1).(func(string) *updater.Update); ok { - r1 = rf(_a0) - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).(*updater.Update) - } - } - - return r0, r1 -} - // Push provides a mock function with given fields: _a0, _a1 func (_m *mockRepo) Push(_a0 context.Context, _a1 updater.Update) error { ret := _m.Called(_a0, _a1) @@ -106,26 +83,3 @@ func (_m *mockRepo) SetBranch(branch string) error { return r0 } - -// Updates provides a mock function with given fields: _a0 -func (_m *mockRepo) Updates(_a0 context.Context) (updater.UpdatesByBranch, error) { - ret := _m.Called(_a0) - - var r0 updater.UpdatesByBranch - if rf, ok := ret.Get(0).(func(context.Context) updater.UpdatesByBranch); ok { - r0 = rf(_a0) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(updater.UpdatesByBranch) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = rf(_a0) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} diff --git a/updater/updater.go b/updater/updater.go index d4d8767..5d3c499 100644 --- a/updater/updater.go +++ b/updater/updater.go @@ -9,9 +9,10 @@ import ( // RepoUpdater creates branches proposing all available updates for a Go module. type RepoUpdater struct { - repo Repo - updater Updater - Batch bool + repo Repo + updater Updater + batchConfig map[string][]string + branchNamer UpdateBranchNamer } // Repo interfaces with an SCM repository, probably Git. @@ -22,17 +23,12 @@ type Repo interface { // SetBranch changes to an existing branch. SetBranch(branch string) error // NewBranch creates and changes to a new branch. - // FIXME: branch naming should be done by Updater, not Repo - NewBranch(baseBranch string, update Update) error + NewBranch(base, branch string) error // Branch returns the current branch. Branch() string // Push snapshots the working tree after an update has been applied, and "publishes". // This is branch to commit. Publishing may mean push, create a PR, tweet the maintainer, whatever. Push(context.Context, Update) error - // OpenUpdates returns any existing updates in the repo. - Updates(context.Context) (UpdatesByBranch, error) - // Parse matches a branch name that may be an update. Nil if not an update branch - Parse(string) (baseBranch string, update *Update) } type Updater interface { @@ -42,16 +38,29 @@ type Updater interface { } // NewRepoUpdater creates RepoUpdater. -func NewRepoUpdater(repo Repo, updater Updater) *RepoUpdater { - return &RepoUpdater{ - repo: repo, - updater: updater, +func NewRepoUpdater(repo Repo, updater Updater, opts ...RepoUpdaterOpt) *RepoUpdater { + u := &RepoUpdater{ + repo: repo, + updater: updater, + branchNamer: DefaultUpdateBranchNamer{}, + } + for _, opt := range opts { + opt(u) + } + return u +} + +type RepoUpdaterOpt func(*RepoUpdater) + +func WithBatches(batchConfig map[string][]string) RepoUpdaterOpt { + return func(u *RepoUpdater) { + u.batchConfig = batchConfig } } // Update creates a single update branch in the Repo. func (u *RepoUpdater) Update(ctx context.Context, baseBranch string, update Update) error { - if err := u.repo.NewBranch(baseBranch, update); err != nil { + if err := u.repo.NewBranch(baseBranch, u.branchNamer.Format(baseBranch, update)); err != nil { return fmt.Errorf("switching to target branch: %w", err) } @@ -68,21 +77,22 @@ func (u *RepoUpdater) Update(ctx context.Context, baseBranch string, update Upda // UpdateAll creates updates from a base branch in the Repo. func (u *RepoUpdater) UpdateAll(ctx context.Context, branches ...string) error { - updatesByBranch, err := u.repo.Updates(ctx) - if err != nil { - return fmt.Errorf("listing open updates: %w", err) - } - multiBranch := len(branches) > 1 for _, branch := range branches { - if err := u.updateBranch(ctx, multiBranch, branch, updatesByBranch); err != nil { + var log logrus.FieldLogger + if multiBranch { + log = logrus.WithField("branch", branch) + } else { + log = logrus.StandardLogger() + } + if err := u.updateBranch(ctx, log, branch); err != nil { return err } } return nil } -func (u *RepoUpdater) updateBranch(ctx context.Context, multiBranch bool, branch string, updatesByBranch UpdatesByBranch) error { +func (u *RepoUpdater) updateBranch(ctx context.Context, log logrus.FieldLogger, branch string) error { // Switch to base branch: if err := u.repo.SetBranch(branch); err != nil { return fmt.Errorf("switch to base branch: %w", err) @@ -93,53 +103,53 @@ func (u *RepoUpdater) updateBranch(ctx context.Context, multiBranch bool, branch if err != nil { return fmt.Errorf("getting dependencies: %w", err) } + batches := GroupDependencies(u.batchConfig, deps) + log.WithFields(logrus.Fields{ + "deps": len(deps), + "batches": len(batches), + }).Info("parsed dependencies, checking for updates") + + updates := 0 + for batchBranch, batchDeps := range batches { + // Iterate dependencies, collecting updates: + var batchUpdates []Update + for _, dep := range batchDeps { + // Is an update available for this dependency? + depLog := log.WithField("path", dep.Path) + update := u.checkForUpdate(ctx, depLog, dep) + if update == nil { + continue + } + // There is an update to apply + depLog.WithField("next_version", update.Next).Debug("update available") + batchUpdates = append(batchUpdates, *update) + } - var log logrus.FieldLogger - if multiBranch { - log = logrus.WithField("branch", branch) - } else { - log = logrus.StandardLogger() - } - log.WithField("deps", len(deps)).Info("parsed dependencies, checking for updates") - - // Iterate dependencies, collecting updates: - existingUpdates := updatesByBranch[branch] - var updates []Update - for _, dep := range deps { - // Is an update available for this dependency? - depLog := log.WithField("path", dep.Path) - update := u.checkForUpdate(ctx, depLog, existingUpdates, dep) - if update == nil { + if len(batchUpdates) == 0 { continue } - // There is an update to apply - depLog = depLog.WithField("next_version", update.Next) - updates = append(updates, *update) - - // When not batching, delegate to the standalone .Update() process - if !u.Batch { - if err := u.Update(ctx, branch, *update); err != nil { - depLog.WithError(err).Warn("error applying update") - continue + if batchBranch != "" { + if err := u.batchedUpdate(ctx, branch, batchBranch, batchUpdates); err != nil { + return err + } + } else { + if err := u.serialUpdates(ctx, branch, batchUpdates); err != nil { + return err } } - } - if u.Batch { - if err := u.batchedUpdate(ctx, branch, updates); err != nil { - return err - } + updates += len(batchUpdates) } - log.WithFields(logrus.Fields{ "deps": len(deps), - "updates": len(updates), + "updates": updates, }).Info("checked for updates") + return nil } -func (u *RepoUpdater) checkForUpdate(ctx context.Context, log logrus.FieldLogger, existing Updates, dep Dependency) *Update { +func (u *RepoUpdater) checkForUpdate(ctx context.Context, log logrus.FieldLogger, dep Dependency) *Update { update, err := u.updater.Check(ctx, dep) if err != nil { log.WithError(err).Warn("error checking for updates") @@ -149,25 +159,28 @@ func (u *RepoUpdater) checkForUpdate(ctx context.Context, log logrus.FieldLogger return nil } - if existing := existing.Filter(*update); existing != "" { - // XXX: can we link to the conflict? (e.g. PR url) - log.WithFields(logrus.Fields{ - "next_version": update.Next, - "existing_version": existing, - }).Info("filtering existing version") - return nil - } return update } -func (u *RepoUpdater) batchedUpdate(ctx context.Context, branch string, updates []Update) error { - // XXX: better ideas here, this was quick - batchUpdate := Update{ - Path: "github.com/thepwagner/action-update-go", - Next: "BATCH", +func (u *RepoUpdater) serialUpdates(ctx context.Context, base string, updates []Update) error { + for _, update := range updates { + branch := u.branchNamer.Format(base, update) + if err := u.repo.NewBranch(base, branch); err != nil { + return fmt.Errorf("switching to target branch: %w", err) + } + if err := u.updater.ApplyUpdate(ctx, update); err != nil { + return fmt.Errorf("applying batched update: %w", err) + } + if err := u.repo.Push(ctx, update); err != nil { + return fmt.Errorf("pushing update: %w", err) + } } + return nil +} - if err := u.repo.NewBranch(branch, batchUpdate); err != nil { +func (u *RepoUpdater) batchedUpdate(ctx context.Context, base, batchName string, updates []Update) error { + branch := u.branchNamer.FormatBatch(base, batchName) + if err := u.repo.NewBranch(base, branch); err != nil { return fmt.Errorf("switching to target branch: %w", err) } @@ -177,8 +190,21 @@ func (u *RepoUpdater) batchedUpdate(ctx context.Context, branch string, updates } } - if err := u.repo.Push(ctx, batchUpdate); err != nil { + var update Update + if len(updates) == 1 { + update = updates[0] + } else { + // TODO: awkward for GitHubRepo, which will try to link a changelog here? + update = Update{Path: branch} + } + + if err := u.repo.Push(ctx, update); err != nil { return fmt.Errorf("pushing update: %w", err) } + return nil } + +func (u *RepoUpdater) Parse(branch string) (baseBranch string, update *Update) { + return u.branchNamer.Parse(branch) +} diff --git a/updater/updater_test.go b/updater/updater_test.go index 25328a2..4512306 100644 --- a/updater/updater_test.go +++ b/updater/updater_test.go @@ -2,6 +2,7 @@ package updater_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/mock" @@ -25,7 +26,8 @@ func TestRepoUpdater_Update(t *testing.T) { } func setupMockUpdate(ctx context.Context, r *mockRepo, u *mockUpdater, up updater.Update) { - r.On("NewBranch", baseBranch, up).Return(nil) + branch := fmt.Sprintf("action-update-go/main/%s/%s", up.Path, up.Next) + r.On("NewBranch", baseBranch, branch).Return(nil) u.On("ApplyUpdate", ctx, up).Return(nil) r.On("Push", ctx, up).Return(nil) } @@ -36,7 +38,6 @@ func TestRepoUpdater_UpdateAll_NoChanges(t *testing.T) { ru := updater.NewRepoUpdater(r, u) ctx := context.Background() - r.On("Updates", ctx).Return(updater.UpdatesByBranch{}, nil) r.On("SetBranch", baseBranch).Return(nil) dep := updater.Dependency{Path: mockUpdate.Path, Version: mockUpdate.Previous} u.On("Dependencies", ctx).Return([]updater.Dependency{dep}, nil) @@ -54,7 +55,6 @@ func TestRepoUpdater_UpdateAll_Update(t *testing.T) { ru := updater.NewRepoUpdater(r, u) ctx := context.Background() - r.On("Updates", ctx).Return(updater.UpdatesByBranch{}, nil) r.On("SetBranch", baseBranch).Return(nil) dep := updater.Dependency{Path: mockUpdate.Path, Version: mockUpdate.Previous} u.On("Dependencies", ctx).Return([]updater.Dependency{dep}, nil) @@ -74,7 +74,6 @@ func TestRepoUpdater_UpdateAll_Multiple(t *testing.T) { ru := updater.NewRepoUpdater(r, u) ctx := context.Background() - r.On("Updates", ctx).Return(updater.UpdatesByBranch{}, nil) r.On("SetBranch", baseBranch).Return(nil) dep := updater.Dependency{Path: mockUpdate.Path, Version: mockUpdate.Previous} otherDep := updater.Dependency{Path: "github.com/foo/baz", Version: mockUpdate.Previous} @@ -95,11 +94,10 @@ func TestRepoUpdater_UpdateAll_Multiple(t *testing.T) { func TestRepoUpdater_UpdateAll_MultipleBatch(t *testing.T) { r := &mockRepo{} u := &mockUpdater{} - ru := updater.NewRepoUpdater(r, u) - ru.Batch = true + batchName := "foo" + ru := updater.NewRepoUpdater(r, u, updater.WithBatches(map[string][]string{batchName: {"github.com/foo"}})) ctx := context.Background() - r.On("Updates", ctx).Return(updater.UpdatesByBranch{}, nil) r.On("SetBranch", baseBranch).Return(nil) dep := updater.Dependency{Path: mockUpdate.Path, Version: mockUpdate.Previous} otherDep := updater.Dependency{Path: "github.com/foo/baz", Version: mockUpdate.Previous} @@ -109,7 +107,7 @@ func TestRepoUpdater_UpdateAll_MultipleBatch(t *testing.T) { otherUpdate := updater.Update{Path: "github.com/foo/baz", Next: "v3.0.0"} u.On("Check", ctx, otherDep).Return(&otherUpdate, nil) - r.On("NewBranch", baseBranch, mock.Anything).Times(1).Return(nil) + r.On("NewBranch", baseBranch, "action-update-go/main/foo").Times(1).Return(nil) u.On("ApplyUpdate", ctx, mock.Anything).Times(2).Return(nil) r.On("Push", ctx, mock.Anything).Times(1).Return(nil) diff --git a/vendor/modules.txt b/vendor/modules.txt index b93e4c0..d534d94 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -184,6 +184,7 @@ gopkg.in/warnings.v0 # gopkg.in/yaml.v2 v2.3.0 ## explicit # gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c +## explicit gopkg.in/yaml.v3 # github.com/containerd/containerd => github.com/containerd/containerd v1.4.0 # github.com/docker/docker => github.com/moby/moby v17.12.0-ce-rc1.0.20200916142827-bd33bbf0497b+incompatible