Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(foreach): run against previously failed or successful repos #147

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ 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
divided into `successful` and `failed` subdirectories. Each of these also contains a separate file listing all the repositories that succeeded or failed.

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

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

### Committing changes

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

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

overallResultsDirectory string

successfulResultsDirectory string
successfulReposFileName string
successfulReposSymlink = ".latest_successful"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we definitely need two symlinks? If we have our output directories to be something like:

some_temp_dir_from_os/
    some_random_bit/
        successful/
            repos.txt
            logs/
        failed/
            repos.txt
            logs/

Then we could just use one symlink, to the /some_temp_dir_from_os/some_random_bit path and infer the rest as needed.


failedResultsDirectory string
failedReposFileName string
failedReposSymlink = ".latest_failed"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those could be const?

)

func formatArguments(arguments []string) string {
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 @@ -65,7 +80,9 @@ marks that no further options should be interpreted by turbolift.`,
Args: cobra.MinimumNArgs(1),
}

cmd.Flags().StringVar(&repoFile, "repos", "repos.txt", "A file containing a list of repositories to clone.")
cmd.Flags().StringVar(&repoFile, "repos", "", "A file containing a list of repositories to clone.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is needed - this mutex between repos.txt and successful/failed seems complicated, should we just ignore the repoFile if --successful or --failed is set?

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,23 @@ func runE(c *cobra.Command, args []string) error {
return errors.New("Use -- to separate command")
}

customRepoFile := repoFile != ""
if moreThanOne(successful, failed, customRepoFile) {
return errors.New("only one repositories flag or option may be specified: either --successful; --failed; or --repos <file>")
}
if successful {
var err error
if repoFile, err = os.Readlink(successfulReposSymlink); err != nil {
return errors.New("no previous successful foreach logs found")
}
} else if failed {
var err error
if repoFile, err = os.Readlink(failedReposSymlink); err != nil {
return errors.New("no previous failed foreach logs found")
}
} else if !customRepoFile {
repoFile = "repos.txt"
}
readCampaignActivity := logger.StartActivity("Reading campaign data (%s)", repoFile)
options := campaign.NewCampaignOptions()
options.RepoFilename = repoFile
Expand All @@ -91,7 +125,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 +162,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 +185,27 @@ func setupOutputFiles(campaignName string, command string) {
defer successfulReposFile.Close()
defer failedReposFile.Close()

if _, err := os.Lstat(successfulReposSymlink); err == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can prob just override it?

err := os.Remove(successfulReposSymlink)
if err != nil {
logger.Warnf("Failed to remove previous symlink for successful repos: %v", err)
}
}
err := os.Symlink(successfulReposFileName, successfulReposSymlink)
if err != nil {
logger.Warnf("Failed to create symlink for successful repos: %v", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warn or fail?
Could there be a reason why this should fail?

}
if _, err := os.Lstat(failedReposSymlink); err == nil {
err := os.Remove(failedReposSymlink)
if err != nil {
logger.Warnf("Failed to remove previous symlink for failed repos: %v", err)
}
}
err = os.Symlink(failedReposFileName, failedReposSymlink)
if err != nil {
logger.Warnf("Failed to create symlink for failed repos: %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
180 changes: 180 additions & 0 deletions cmd/foreach/foreach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,133 @@ 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")
testsupport.CreateAnotherRepoFile("successful.txt", "org/repo1", "org/repo3")
testsupport.CreateNewSymlink("successful.txt", ".latest_successful")

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"},
})

// check that the symlink has been updated
successfulRepoFile, err := os.Readlink(".latest_successful")
if err != nil {
panic(err)
}
assert.NotEqual(t, successfulRepoFile, "successful.txt")
}

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

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
testsupport.CreateAnotherRepoFile("failed.txt", "org/repo1", "org/repo3")
testsupport.CreateNewSymlink("failed.txt", ".latest_failed")

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"},
})

// check that the symlink has been updated
failedRepoFile, err := os.Readlink(".latest_failed")
if err != nil {
panic(err)
}
assert.NotEqual(t, failedRepoFile, "failed.txt")
}

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")

successfulRepoFile, err := os.Readlink(".latest_successful")
if err != nil {
panic(err)
}
successfulRepos, err := os.ReadFile(successfulRepoFile)
if err != nil {
panic(err)
}
assert.Contains(t, string(successfulRepos), "org/repo1")
assert.Contains(t, string(successfulRepos), "org/repo3")
assert.NotContains(t, string(successfulRepos), "org/repo2")

failedRepoFile, err := os.Readlink(".latest_failed")
if err != nil {
panic(err)
}
failedRepos, err := os.ReadFile(failedRepoFile)
if err != nil {
panic(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 runCommand(args ...string) (string, error) {
cmd := NewForeachCmd()
outBuffer := bytes.NewBufferString("")
Expand All @@ -215,3 +342,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
}
13 changes: 13 additions & 0 deletions internal/testsupport/testsupport.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,16 @@ func UsePrTitleTodoOnly() {
func UsePrBodyTodoOnly() {
CreateOrUpdatePrDescriptionFile("README.md", "updated PR title", originalPrBodyTodo)
}

func CreateNewSymlink(target string, linkName string) {
if _, err := os.Lstat(linkName); err == nil {
err = os.Remove(linkName)
if err != nil {
panic(err)
}
}
err := os.Symlink(target, linkName)
if err != nil {
panic(err)
}
}
Loading