Skip to content

Commit

Permalink
feat(foreach): run against previously failed or successful repos (#147)
Browse files Browse the repository at this point in the history
* implement symlinks and tests

* use a single symlink

* update error handling

* readme update

* minor changes

---------

Co-authored-by: Danny Ranson <[email protected]>
  • Loading branch information
Dan7-7-7 and Danny Ranson authored Nov 18, 2024
1 parent 557329a commit 1a79b73
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 10 deletions.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,32 @@ At any time, if you need to update your working copy branches from the upstream,

It is highly recommended that you run tests against affected repos, if it will help validate the changes you have made.

#### Logging and re-running with foreach

Every time a command is run with `turbolift foreach`, logging output for each repository is collected in a temporary directory
with the following structure:

```
temp-dir
\ successful
\ repos.txt # a list of repos where the command succeeded
\ org
\ repo
\ logs.txt # logs from the specific foreach execution on this repo
....
\ failed
\ repos.txt # a list of repos where the command succeeded
\ org
\ repo
\ logs.txt # logs from the specific foreach execution on this repo
```

You can use `--successful` or `--failed` to run a foreach command only against the repositories that succeeded or failed in the preceding foreach execution.

```
turbolift foreach --failed -- make test
```

### Committing changes

When ready to commit changes across all repos, run:
Expand Down
59 changes: 54 additions & 5 deletions cmd/foreach/foreach.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ import (
var exec executor.Executor = executor.NewRealExecutor()

var (
repoFile = "repos.txt"
repoFile = "repos.txt"
successful bool
failed bool

overallResultsDirectory string

Expand All @@ -46,6 +48,8 @@ var (
failedReposFileName string
)

const previousResultsSymlink = "..turbolift_previous_results"

func formatArguments(arguments []string) string {
quotedArgs := make([]string, len(arguments))
for i, arg := range arguments {
Expand All @@ -54,6 +58,17 @@ func formatArguments(arguments []string) string {
return strings.Join(quotedArgs, " ")
}

func moreThanOne(args ...bool) bool {
b := map[bool]int{
false: 0,
true: 0,
}
for _, v := range args {
b[v] += 1
}
return b[true] > 1
}

func NewForeachCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "foreach [flags] -- COMMAND [ARGUMENT...]",
Expand All @@ -66,6 +81,8 @@ marks that no further options should be interpreted by turbolift.`,
}

cmd.Flags().StringVar(&repoFile, "repos", "repos.txt", "A file containing a list of repositories to clone.")
cmd.Flags().BoolVar(&successful, "successful", false, "Indication of whether to run against previously successful repos only.")
cmd.Flags().BoolVar(&failed, "failed", false, "Indication of whether to run against previously failed repos only.")

return cmd
}
Expand All @@ -77,6 +94,26 @@ func runE(c *cobra.Command, args []string) error {
return errors.New("Use -- to separate command")
}

isCustomRepoFile := repoFile != "repos.txt"
if moreThanOne(successful, failed, isCustomRepoFile) {
return errors.New("a maximum of one repositories flag / option may be specified: either --successful; --failed; or --repos <file>")
}
if successful {
previousResults, err := os.Readlink(previousResultsSymlink)
if err != nil {
return errors.New("no previous foreach logs found")
}
repoFile = path.Join(previousResults, "successful", "repos.txt")
logger.Printf("Running against previously successful repos only")
} else if failed {
previousResults, err := os.Readlink(previousResultsSymlink)
if err != nil {
return errors.New("no previous foreach logs found")
}
repoFile = path.Join(previousResults, "failed", "repos.txt")
logger.Printf("Running against previously failed repos only")
}

readCampaignActivity := logger.StartActivity("Reading campaign data (%s)", repoFile)
options := campaign.NewCampaignOptions()
options.RepoFilename = repoFile
Expand All @@ -91,7 +128,7 @@ func runE(c *cobra.Command, args []string) error {
// the user something they could copy and paste.
prettyArgs := formatArguments(args)

setupOutputFiles(dir.Name, prettyArgs)
setupOutputFiles(dir.Name, prettyArgs, logger)

logger.Printf("Logs for all executions will be stored under %s", overallResultsDirectory)

Expand Down Expand Up @@ -128,14 +165,14 @@ func runE(c *cobra.Command, args []string) error {
}

logger.Printf("Logs for all executions have been stored under %s", overallResultsDirectory)
logger.Printf("Names of successful repos have been written to %s", successfulReposFileName)
logger.Printf("Names of failed repos have been written to %s", failedReposFileName)
logger.Printf("Names of successful repos have been written to %s. Use --successful to run the next foreach command against these repos", successfulReposFileName)
logger.Printf("Names of failed repos have been written to %s. Use --failed to run the next foreach command against these repos", failedReposFileName)

return nil
}

// sets up a temporary directory to store success/failure logs etc
func setupOutputFiles(campaignName string, command string) {
func setupOutputFiles(campaignName string, command string, logger *logging.Logger) {
overallResultsDirectory, _ = os.MkdirTemp("", fmt.Sprintf("turbolift-foreach-%s-", campaignName))
successfulResultsDirectory = path.Join(overallResultsDirectory, "successful")
failedResultsDirectory = path.Join(overallResultsDirectory, "failed")
Expand All @@ -151,6 +188,18 @@ func setupOutputFiles(campaignName string, command string) {
defer successfulReposFile.Close()
defer failedReposFile.Close()

// create symlink to the results
if _, err := os.Lstat(previousResultsSymlink); err == nil {
err := os.Remove(previousResultsSymlink)
if err != nil {
logger.Warnf("Failed to remove previous symlink for successful repos: %v", err)
}
}
err := os.Symlink(overallResultsDirectory, previousResultsSymlink)
if err != nil {
logger.Warnf("Failed to create symlink to foreach results: %v", err)
}

_, _ = successfulReposFile.WriteString(fmt.Sprintf("# This file contains the list of repositories that were successfully processed by turbolift foreach\n# for the command: %s\n", command))
_, _ = failedReposFile.WriteString(fmt.Sprintf("# This file contains the list of repositories that failed to be processed by turbolift foreach\n# for the command: %s\n", command))
}
Expand Down
202 changes: 202 additions & 0 deletions cmd/foreach/foreach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package foreach
import (
"bytes"
"os"
"path"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -204,6 +206,153 @@ func TestItCreatesLogFiles(t *testing.T) {
assert.NoError(t, err, "Expected the failure log file for org/repo2 to exist")
}

func TestItRunsAgainstSuccessfulReposOnly(t *testing.T) {
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
exec = fakeExecutor

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
err := setUpSymlink()
if err != nil {
t.Errorf("Error setting up symlink: %s", err)
}
defer os.RemoveAll("mock_output")

out, err := runCommandReposSuccessful("--", "some", "command")
assert.NoError(t, err)
assert.Contains(t, out, "turbolift foreach completed")
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
assert.Contains(t, out, "org/repo1")
assert.Contains(t, out, "org/repo3")
assert.NotContains(t, out, "org/repo2")

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "some", "command"},
{"work/org/repo3", "some", "command"},
})
}

func TestItRunsAgainstFailedReposOnly(t *testing.T) {
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
exec = fakeExecutor

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
err := setUpSymlink()
if err != nil {
t.Errorf("Error setting up symlink: %s", err)
}
defer os.RemoveAll("mock_output")

out, err := runCommandReposFailed("--", "some", "command")
assert.NoError(t, err)
assert.Contains(t, out, "turbolift foreach completed")
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
assert.Contains(t, out, "org/repo1")
assert.Contains(t, out, "org/repo3")
assert.NotContains(t, out, "org/repo2")

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "some", "command"},
{"work/org/repo3", "some", "command"},
})
}

func TestItCreatesSymlinksSuccessfully(t *testing.T) {
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
exec = fakeExecutor

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")

out, err := runCommand("--", "some", "command")
assert.NoError(t, err)
assert.Contains(t, out, "turbolift foreach completed")
assert.Contains(t, out, "2 OK, 0 skipped, 1 errored")

resultsDir, err := os.Readlink("..turbolift_previous_results")
if err != nil {

t.Errorf("Error reading symlink: %s", err)
}

successfulRepoFile := path.Join(resultsDir, "successful", "repos.txt")
successfulRepos, err := os.ReadFile(successfulRepoFile)
if err != nil {
t.Errorf("Error reading successful repos: %s", err)
}
assert.Contains(t, string(successfulRepos), "org/repo1")
assert.Contains(t, string(successfulRepos), "org/repo3")
assert.NotContains(t, string(successfulRepos), "org/repo2")

failedRepoFile := path.Join(resultsDir, "failed", "repos.txt")
failedRepos, err := os.ReadFile(failedRepoFile)
if err != nil {
t.Errorf("Error reading failed repos: %s", err)
}
assert.Contains(t, string(failedRepos), "org/repo2")
assert.NotContains(t, string(failedRepos), "org/repo1")
assert.NotContains(t, string(failedRepos), "org/repo3")
}

func TestItRunsAgainstCustomReposFile(t *testing.T) {
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
exec = fakeExecutor

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
testsupport.CreateAnotherRepoFile("custom_repofile.txt", "org/repo1", "org/repo3")

out, err := runCommandReposCustom("--", "some", "command")
assert.NoError(t, err)
assert.Contains(t, out, "turbolift foreach completed")
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
assert.Contains(t, out, "org/repo1")
assert.Contains(t, out, "org/repo3")
assert.NotContains(t, out, "org/repo2")

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "some", "command"},
{"work/org/repo3", "some", "command"},
})
}

func TestItDoesNotAllowMultipleReposArguments(t *testing.T) {
fakeExecutor := executor.NewAlwaysSucceedsFakeExecutor()
exec = fakeExecutor

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")

_, err := runCommandReposMultiple("--", "some", "command")
assert.Error(t, err, "only one repositories flag or option may be specified: either --successful; --failed; or --repos <file>")

fakeExecutor.AssertCalledWith(t, [][]string{})
}

func setUpSymlink() error {
err := os.MkdirAll("mock_output/successful", 0755)
if err != nil {
return err
}
err = os.MkdirAll("mock_output/failed", 0755)
if err != nil {
return err
}
err = os.Symlink("mock_output", "..turbolift_previous_results")
if err != nil {
return err
}
_, err = os.Create("mock_output/successful/repos.txt")
if err != nil {
return err
}
_, err = os.Create("mock_output/failed/repos.txt")
if err != nil {
return err
}
repos := []string{"org/repo1", "org/repo3"}
delimitedList := strings.Join(repos, "\n")
_ = os.WriteFile("mock_output/successful/repos.txt", []byte(delimitedList), os.ModePerm|0o644)
_ = os.WriteFile("mock_output/failed/repos.txt", []byte(delimitedList), os.ModePerm|0o644)
return nil
}

func runCommand(args ...string) (string, error) {
cmd := NewForeachCmd()
outBuffer := bytes.NewBufferString("")
Expand All @@ -215,3 +364,56 @@ func runCommand(args ...string) (string, error) {
}
return outBuffer.String(), nil
}

func runCommandReposSuccessful(args ...string) (string, error) {
cmd := NewForeachCmd()
successful = true
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
return outBuffer.String(), err
}
return outBuffer.String(), nil
}

func runCommandReposFailed(args ...string) (string, error) {
cmd := NewForeachCmd()
failed = true
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
return outBuffer.String(), err
}
return outBuffer.String(), nil
}

func runCommandReposCustom(args ...string) (string, error) {
cmd := NewForeachCmd()
repoFile = "custom_repofile.txt"
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
return outBuffer.String(), err
}
return outBuffer.String(), nil
}

func runCommandReposMultiple(args ...string) (string, error) {
cmd := NewForeachCmd()
successful = true
repoFile = "custom_repofile.txt"
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
return outBuffer.String(), err
}
return outBuffer.String(), nil
}
Loading

0 comments on commit 1a79b73

Please sign in to comment.