Skip to content

Commit 1a79b73

Browse files
authored
feat(foreach): run against previously failed or successful repos (#147)
* implement symlinks and tests * use a single symlink * update error handling * readme update * minor changes --------- Co-authored-by: Danny Ranson <[email protected]>
1 parent 557329a commit 1a79b73

File tree

4 files changed

+286
-10
lines changed

4 files changed

+286
-10
lines changed

README.md

+26
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,32 @@ At any time, if you need to update your working copy branches from the upstream,
158158

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

161+
#### Logging and re-running with foreach
162+
163+
Every time a command is run with `turbolift foreach`, logging output for each repository is collected in a temporary directory
164+
with the following structure:
165+
166+
```
167+
temp-dir
168+
\ successful
169+
\ repos.txt # a list of repos where the command succeeded
170+
\ org
171+
\ repo
172+
\ logs.txt # logs from the specific foreach execution on this repo
173+
....
174+
\ failed
175+
\ repos.txt # a list of repos where the command succeeded
176+
\ org
177+
\ repo
178+
\ logs.txt # logs from the specific foreach execution on this repo
179+
```
180+
181+
You can use `--successful` or `--failed` to run a foreach command only against the repositories that succeeded or failed in the preceding foreach execution.
182+
183+
```
184+
turbolift foreach --failed -- make test
185+
```
186+
161187
### Committing changes
162188

163189
When ready to commit changes across all repos, run:

cmd/foreach/foreach.go

+54-5
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ import (
3535
var exec executor.Executor = executor.NewRealExecutor()
3636

3737
var (
38-
repoFile = "repos.txt"
38+
repoFile = "repos.txt"
39+
successful bool
40+
failed bool
3941

4042
overallResultsDirectory string
4143

@@ -46,6 +48,8 @@ var (
4648
failedReposFileName string
4749
)
4850

51+
const previousResultsSymlink = "..turbolift_previous_results"
52+
4953
func formatArguments(arguments []string) string {
5054
quotedArgs := make([]string, len(arguments))
5155
for i, arg := range arguments {
@@ -54,6 +58,17 @@ func formatArguments(arguments []string) string {
5458
return strings.Join(quotedArgs, " ")
5559
}
5660

61+
func moreThanOne(args ...bool) bool {
62+
b := map[bool]int{
63+
false: 0,
64+
true: 0,
65+
}
66+
for _, v := range args {
67+
b[v] += 1
68+
}
69+
return b[true] > 1
70+
}
71+
5772
func NewForeachCmd() *cobra.Command {
5873
cmd := &cobra.Command{
5974
Use: "foreach [flags] -- COMMAND [ARGUMENT...]",
@@ -66,6 +81,8 @@ marks that no further options should be interpreted by turbolift.`,
6681
}
6782

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

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

97+
isCustomRepoFile := repoFile != "repos.txt"
98+
if moreThanOne(successful, failed, isCustomRepoFile) {
99+
return errors.New("a maximum of one repositories flag / option may be specified: either --successful; --failed; or --repos <file>")
100+
}
101+
if successful {
102+
previousResults, err := os.Readlink(previousResultsSymlink)
103+
if err != nil {
104+
return errors.New("no previous foreach logs found")
105+
}
106+
repoFile = path.Join(previousResults, "successful", "repos.txt")
107+
logger.Printf("Running against previously successful repos only")
108+
} else if failed {
109+
previousResults, err := os.Readlink(previousResultsSymlink)
110+
if err != nil {
111+
return errors.New("no previous foreach logs found")
112+
}
113+
repoFile = path.Join(previousResults, "failed", "repos.txt")
114+
logger.Printf("Running against previously failed repos only")
115+
}
116+
80117
readCampaignActivity := logger.StartActivity("Reading campaign data (%s)", repoFile)
81118
options := campaign.NewCampaignOptions()
82119
options.RepoFilename = repoFile
@@ -91,7 +128,7 @@ func runE(c *cobra.Command, args []string) error {
91128
// the user something they could copy and paste.
92129
prettyArgs := formatArguments(args)
93130

94-
setupOutputFiles(dir.Name, prettyArgs)
131+
setupOutputFiles(dir.Name, prettyArgs, logger)
95132

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

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

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

134171
return nil
135172
}
136173

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

191+
// create symlink to the results
192+
if _, err := os.Lstat(previousResultsSymlink); err == nil {
193+
err := os.Remove(previousResultsSymlink)
194+
if err != nil {
195+
logger.Warnf("Failed to remove previous symlink for successful repos: %v", err)
196+
}
197+
}
198+
err := os.Symlink(overallResultsDirectory, previousResultsSymlink)
199+
if err != nil {
200+
logger.Warnf("Failed to create symlink to foreach results: %v", err)
201+
}
202+
154203
_, _ = 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))
155204
_, _ = 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))
156205
}

cmd/foreach/foreach_test.go

+202
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ package foreach
1818
import (
1919
"bytes"
2020
"os"
21+
"path"
2122
"regexp"
23+
"strings"
2224
"testing"
2325

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

209+
func TestItRunsAgainstSuccessfulReposOnly(t *testing.T) {
210+
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
211+
exec = fakeExecutor
212+
213+
testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
214+
err := setUpSymlink()
215+
if err != nil {
216+
t.Errorf("Error setting up symlink: %s", err)
217+
}
218+
defer os.RemoveAll("mock_output")
219+
220+
out, err := runCommandReposSuccessful("--", "some", "command")
221+
assert.NoError(t, err)
222+
assert.Contains(t, out, "turbolift foreach completed")
223+
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
224+
assert.Contains(t, out, "org/repo1")
225+
assert.Contains(t, out, "org/repo3")
226+
assert.NotContains(t, out, "org/repo2")
227+
228+
fakeExecutor.AssertCalledWith(t, [][]string{
229+
{"work/org/repo1", "some", "command"},
230+
{"work/org/repo3", "some", "command"},
231+
})
232+
}
233+
234+
func TestItRunsAgainstFailedReposOnly(t *testing.T) {
235+
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
236+
exec = fakeExecutor
237+
238+
testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
239+
err := setUpSymlink()
240+
if err != nil {
241+
t.Errorf("Error setting up symlink: %s", err)
242+
}
243+
defer os.RemoveAll("mock_output")
244+
245+
out, err := runCommandReposFailed("--", "some", "command")
246+
assert.NoError(t, err)
247+
assert.Contains(t, out, "turbolift foreach completed")
248+
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
249+
assert.Contains(t, out, "org/repo1")
250+
assert.Contains(t, out, "org/repo3")
251+
assert.NotContains(t, out, "org/repo2")
252+
253+
fakeExecutor.AssertCalledWith(t, [][]string{
254+
{"work/org/repo1", "some", "command"},
255+
{"work/org/repo3", "some", "command"},
256+
})
257+
}
258+
259+
func TestItCreatesSymlinksSuccessfully(t *testing.T) {
260+
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
261+
exec = fakeExecutor
262+
263+
testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
264+
265+
out, err := runCommand("--", "some", "command")
266+
assert.NoError(t, err)
267+
assert.Contains(t, out, "turbolift foreach completed")
268+
assert.Contains(t, out, "2 OK, 0 skipped, 1 errored")
269+
270+
resultsDir, err := os.Readlink("..turbolift_previous_results")
271+
if err != nil {
272+
273+
t.Errorf("Error reading symlink: %s", err)
274+
}
275+
276+
successfulRepoFile := path.Join(resultsDir, "successful", "repos.txt")
277+
successfulRepos, err := os.ReadFile(successfulRepoFile)
278+
if err != nil {
279+
t.Errorf("Error reading successful repos: %s", err)
280+
}
281+
assert.Contains(t, string(successfulRepos), "org/repo1")
282+
assert.Contains(t, string(successfulRepos), "org/repo3")
283+
assert.NotContains(t, string(successfulRepos), "org/repo2")
284+
285+
failedRepoFile := path.Join(resultsDir, "failed", "repos.txt")
286+
failedRepos, err := os.ReadFile(failedRepoFile)
287+
if err != nil {
288+
t.Errorf("Error reading failed repos: %s", err)
289+
}
290+
assert.Contains(t, string(failedRepos), "org/repo2")
291+
assert.NotContains(t, string(failedRepos), "org/repo1")
292+
assert.NotContains(t, string(failedRepos), "org/repo3")
293+
}
294+
295+
func TestItRunsAgainstCustomReposFile(t *testing.T) {
296+
fakeExecutor := executor.NewAlternatingSuccessFakeExecutor()
297+
exec = fakeExecutor
298+
299+
testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
300+
testsupport.CreateAnotherRepoFile("custom_repofile.txt", "org/repo1", "org/repo3")
301+
302+
out, err := runCommandReposCustom("--", "some", "command")
303+
assert.NoError(t, err)
304+
assert.Contains(t, out, "turbolift foreach completed")
305+
assert.Contains(t, out, "1 OK, 0 skipped, 1 errored")
306+
assert.Contains(t, out, "org/repo1")
307+
assert.Contains(t, out, "org/repo3")
308+
assert.NotContains(t, out, "org/repo2")
309+
310+
fakeExecutor.AssertCalledWith(t, [][]string{
311+
{"work/org/repo1", "some", "command"},
312+
{"work/org/repo3", "some", "command"},
313+
})
314+
}
315+
316+
func TestItDoesNotAllowMultipleReposArguments(t *testing.T) {
317+
fakeExecutor := executor.NewAlwaysSucceedsFakeExecutor()
318+
exec = fakeExecutor
319+
320+
testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2", "org/repo3")
321+
322+
_, err := runCommandReposMultiple("--", "some", "command")
323+
assert.Error(t, err, "only one repositories flag or option may be specified: either --successful; --failed; or --repos <file>")
324+
325+
fakeExecutor.AssertCalledWith(t, [][]string{})
326+
}
327+
328+
func setUpSymlink() error {
329+
err := os.MkdirAll("mock_output/successful", 0755)
330+
if err != nil {
331+
return err
332+
}
333+
err = os.MkdirAll("mock_output/failed", 0755)
334+
if err != nil {
335+
return err
336+
}
337+
err = os.Symlink("mock_output", "..turbolift_previous_results")
338+
if err != nil {
339+
return err
340+
}
341+
_, err = os.Create("mock_output/successful/repos.txt")
342+
if err != nil {
343+
return err
344+
}
345+
_, err = os.Create("mock_output/failed/repos.txt")
346+
if err != nil {
347+
return err
348+
}
349+
repos := []string{"org/repo1", "org/repo3"}
350+
delimitedList := strings.Join(repos, "\n")
351+
_ = os.WriteFile("mock_output/successful/repos.txt", []byte(delimitedList), os.ModePerm|0o644)
352+
_ = os.WriteFile("mock_output/failed/repos.txt", []byte(delimitedList), os.ModePerm|0o644)
353+
return nil
354+
}
355+
207356
func runCommand(args ...string) (string, error) {
208357
cmd := NewForeachCmd()
209358
outBuffer := bytes.NewBufferString("")
@@ -215,3 +364,56 @@ func runCommand(args ...string) (string, error) {
215364
}
216365
return outBuffer.String(), nil
217366
}
367+
368+
func runCommandReposSuccessful(args ...string) (string, error) {
369+
cmd := NewForeachCmd()
370+
successful = true
371+
outBuffer := bytes.NewBufferString("")
372+
cmd.SetOut(outBuffer)
373+
cmd.SetArgs(args)
374+
err := cmd.Execute()
375+
if err != nil {
376+
return outBuffer.String(), err
377+
}
378+
return outBuffer.String(), nil
379+
}
380+
381+
func runCommandReposFailed(args ...string) (string, error) {
382+
cmd := NewForeachCmd()
383+
failed = true
384+
outBuffer := bytes.NewBufferString("")
385+
cmd.SetOut(outBuffer)
386+
cmd.SetArgs(args)
387+
err := cmd.Execute()
388+
if err != nil {
389+
return outBuffer.String(), err
390+
}
391+
return outBuffer.String(), nil
392+
}
393+
394+
func runCommandReposCustom(args ...string) (string, error) {
395+
cmd := NewForeachCmd()
396+
repoFile = "custom_repofile.txt"
397+
outBuffer := bytes.NewBufferString("")
398+
cmd.SetOut(outBuffer)
399+
cmd.SetArgs(args)
400+
err := cmd.Execute()
401+
if err != nil {
402+
return outBuffer.String(), err
403+
}
404+
return outBuffer.String(), nil
405+
}
406+
407+
func runCommandReposMultiple(args ...string) (string, error) {
408+
cmd := NewForeachCmd()
409+
successful = true
410+
repoFile = "custom_repofile.txt"
411+
outBuffer := bytes.NewBufferString("")
412+
cmd.SetOut(outBuffer)
413+
cmd.SetArgs(args)
414+
err := cmd.Execute()
415+
if err != nil {
416+
return outBuffer.String(), err
417+
}
418+
return outBuffer.String(), nil
419+
}

0 commit comments

Comments
 (0)