Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ Note that when updating PR descriptions, as when creating PRs, the `--descriptio
alternative description file to the default `README.md`.
The updated title is taken from the first line of the file, and the updated description is the remainder of the file contents.

#### Cleaning up Forks

Once PRs are merged, you may wish to clean up any forks that were created for the Turbolift campaign. Run `turbolift cleanup` to generate a
`.cleanup.txt` file with a list of forks that are safe to delete, along with instructions on how to delete them.

Note: "safe to delete" means that Turbolift has not detected any open upstream PRs from a given fork. However, it is your responsibility to ensure
that this is actually the case and that the forks are in fact safe to delete.

## Status: Preview

This tool is fully functional, but we have improvements that we'd like to make, and would appreciate feedback.
Expand Down
145 changes: 145 additions & 0 deletions cmd/cleanup/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright 2021 Skyscanner Limited.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package cleanup

import (
"github.com/skyscanner/turbolift/internal/campaign"
"github.com/skyscanner/turbolift/internal/colors"
"github.com/skyscanner/turbolift/internal/github"
"github.com/skyscanner/turbolift/internal/logging"
"github.com/spf13/cobra"
"os"
"path"
)

var (
gh github.GitHub = github.NewRealGitHub()
cleanupFile = ".cleanup.txt"
repoFile string
)

func NewCleanupCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cleanup",
Short: "Cleans up forks used in this campaign",
Run: run,
}

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

return cmd
}

func run(c *cobra.Command, _ []string) {
logger := logging.NewLogger(c)

readCampaignActivity := logger.StartActivity("Reading campaign data (%s)", repoFile)
options := campaign.NewCampaignOptions()
options.RepoFilename = repoFile
dir, err := campaign.OpenCampaign(options)
if err != nil {
readCampaignActivity.EndWithFailure(err)
return
}
readCampaignActivity.EndWithSuccess()

cleanupFileActivity := logger.StartActivity("Creating cleanup file (%s)", cleanupFile)
deletableForks, err := os.Create(cleanupFile)
if err != nil {
cleanupFileActivity.EndWithFailure(err)
return
}
cleanupFileActivity.EndWithSuccess()

defer func(deletableForks *os.File) {
err := deletableForks.Close()
if err != nil {
logger.Errorf("Error closing cleanup file: %s", err)
}
}(deletableForks)

forksFound := false
deletableForksFound := false
var doneCount, errorCount, skippedCount int
for _, repo := range dir.Repos {

forkStatusActivity := logger.StartActivity("Checking whether %s is a fork", repo.FullRepoName)
repoDirPath := path.Join("work", repo.OrgName, repo.RepoName)
isFork, err := gh.IsFork(logger.Writer(), repoDirPath)
if err != nil {
errorCount++
forkStatusActivity.EndWithFailure(err)
continue
}
if !isFork {
skippedCount++
forkStatusActivity.EndWithSuccess()
continue
}
forkStatusActivity.EndWithSuccess()

forksFound = true

prCheckActivity := logger.StartActivity("Checking for open PRs in %s", repo.FullRepoName)
openUpstreamPR, err := gh.UserHasOpenUpstreamPRs(logger.Writer(), repo.FullRepoName)
if err != nil {
errorCount++
prCheckActivity.EndWithFailure(err)
continue
}
prCheckActivity.EndWithSuccess()
if !openUpstreamPR {
deletableForkActivity := logger.StartActivity("Adding fork of %s to cleanup file", repo.FullRepoName)
originRepoName, err := gh.GetOriginRepoName(logger.Writer(), repoDirPath)
if err != nil {
errorCount++
deletableForkActivity.EndWithFailure(err)
continue
}
_, err = deletableForks.WriteString(originRepoName + "\n")
if err != nil {
errorCount++
deletableForkActivity.EndWithFailure(err)
continue
}
deletableForkActivity.EndWithSuccess()
deletableForksFound = true
}
doneCount++
}

if errorCount == 0 {
logger.Successf("turbolift cleanup completed %s(%s forks checked, %s non-forks skipped)\n", colors.Normal(), colors.Green(doneCount), colors.Yellow(skippedCount))
if deletableForksFound {
logger.Printf(" %s contains a list of forks used in this campaign that do not currently have an upstream PR open. Please check over these carefully. It is your responsibility to ensure that they are in fact safe to delete.", cleanupFile)
logger.Println("If you wish to delete these forks, run the following command:")
logger.Printf(" for f in $(cat %s); do", cleanupFile)
logger.Println(" gh repo delete --yes $f")
logger.Println(" sleep 1")
logger.Println(" done")
} else {
if forksFound {
logger.Println("All forks used in this campaign appear to have an open upstream PR. No cleanup can be done at this time.")
} else {
logger.Println("No forks found in this campaign.")
}
}
} else {
logger.Errorf("turbolift cleanup completed with errors")
logger.Warnf("turbolift cleanup completed with %s %s(%s forks checked, %s non-forks skipped, %s errored)\n", colors.Red("errors"), colors.Normal(), colors.Green(doneCount), colors.Yellow(skippedCount), colors.Red(errorCount))
logger.Println("Please check errors above and fix if necessary")
}
}
164 changes: 164 additions & 0 deletions cmd/cleanup/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2021 Skyscanner Limited.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package cleanup

import (
"bytes"
"errors"
"testing"

"github.com/stretchr/testify/assert"

"github.com/skyscanner/turbolift/internal/github"
"github.com/skyscanner/turbolift/internal/testsupport"
)

func TestItWritesDeletableForksToFile(t *testing.T) {
fakeGitHub := github.NewFakeGitHub(func(command github.Command, args []string) (bool, error) {
switch command {
case github.IsFork:
return true, nil
case github.UserHasOpenUpstreamPRs:
if args[1] == "org/repo1" {
return false, nil
} else {
return true, nil
}
default:
return false, errors.New("unexpected command")
}
}, func(workingDir string) (interface{}, error) {
return "org/repo", nil
})

gh = fakeGitHub

testsupport.PrepareTempCampaign(false, "org/repo1", "org/repo2")

out, err := runCleanupCommand()
assert.NoError(t, err)
assert.Contains(t, out, "turbolift cleanup completed (2 forks checked, 0 non-forks skipped)")
assert.Contains(t, out, "If you wish to delete these forks, run the following command:")
assert.FileExists(t, cleanupFile)

fakeGitHub.AssertCalledWith(t, [][]string{
{"is_fork", "work/org/repo1"},
{"user_has_open_upstream_prs", "org/repo1"},
{"get_origin_repo_name", "work/org/repo1"},
{"is_fork", "work/org/repo2"},
{"user_has_open_upstream_prs", "org/repo2"},
})
}

func TestItWritesNothingWhenNoForksAreDeletable(t *testing.T) {
fakeGitHub := github.NewFakeGitHub(func(command github.Command, args []string) (bool, error) {
switch command {
case github.IsFork:
return true, nil
case github.UserHasOpenUpstreamPRs:
return true, nil
default:
return false, errors.New("unexpected command")
}
}, func(workingDir string) (interface{}, error) {
return nil, errors.New("unexpected call")
})

gh = fakeGitHub

testsupport.PrepareTempCampaign(false, "org/repo1", "org/repo2")

out, err := runCleanupCommand()
assert.NoError(t, err)
assert.Contains(t, out, "turbolift cleanup completed (2 forks checked, 0 non-forks skipped)")
assert.Contains(t, out, "All forks used in this campaign appear to have an open upstream PR.")

fakeGitHub.AssertCalledWith(t, [][]string{
{"is_fork", "work/org/repo1"},
{"user_has_open_upstream_prs", "org/repo1"},
{"is_fork", "work/org/repo2"},
{"user_has_open_upstream_prs", "org/repo2"},
})
}

func TestItSkipsNonForksButContinuesToTryAll(t *testing.T) {
fakeGitHub := github.NewFakeGitHub(func(command github.Command, args []string) (bool, error) {
switch command {
case github.IsFork:
return false, nil
default:
return false, errors.New("unexpected command")
}
}, func(workingDir string) (interface{}, error) {
return nil, errors.New("unexpected call")
})

gh = fakeGitHub

testsupport.PrepareTempCampaign(false, "org/repo1", "org/repo2")

out, err := runCleanupCommand()
assert.NoError(t, err)
assert.Contains(t, out, "turbolift cleanup completed (0 forks checked, 2 non-forks skipped)")

fakeGitHub.AssertCalledWith(t, [][]string{
{"is_fork", "work/org/repo1"},
{"is_fork", "work/org/repo2"},
})
}

func TestItWarnsOnErrorButContinuesToTryAll(t *testing.T) {
fakeGitHub := github.NewFakeGitHub(func(command github.Command, args []string) (bool, error) {
switch command {
case github.IsFork:
return true, nil
case github.UserHasOpenUpstreamPRs:
return false, errors.New("synthetic error")
default:
return false, errors.New("unexpected command")
}
}, func(workingDir string) (interface{}, error) {
return nil, errors.New("unexpected call")
})

gh = fakeGitHub

testsupport.PrepareTempCampaign(false, "org/repo1", "org/repo2")

out, err := runCleanupCommand()
assert.NoError(t, err)
assert.Contains(t, out, "turbolift cleanup completed with errors (0 forks checked, 0 non-forks skipped, 2 errored)")
assert.Contains(t, out, "Please check errors above and fix if necessary")
assert.FileExists(t, cleanupFile)

fakeGitHub.AssertCalledWith(t, [][]string{
{"is_fork", "work/org/repo1"},
{"user_has_open_upstream_prs", "org/repo1"},
{"is_fork", "work/org/repo2"},
{"user_has_open_upstream_prs", "org/repo2"},
})
}

func runCleanupCommand() (string, error) {
cmd := NewCleanupCmd()
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
err := cmd.Execute()
if err != nil {
return outBuffer.String(), err
}
return outBuffer.String(), nil
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/spf13/cobra"

cleanupCmd "github.com/skyscanner/turbolift/cmd/cleanup"
cloneCmd "github.com/skyscanner/turbolift/cmd/clone"
commitCmd "github.com/skyscanner/turbolift/cmd/commit"
createPrsCmd "github.com/skyscanner/turbolift/cmd/create_prs"
Expand Down Expand Up @@ -48,6 +49,7 @@ var rootCmd = &cobra.Command{
func init() {
rootCmd.PersistentFlags().BoolVarP(&flags.Verbose, "verbose", "v", false, "verbose output")

rootCmd.AddCommand(cleanupCmd.NewCleanupCmd())
rootCmd.AddCommand(cloneCmd.NewCloneCmd())
rootCmd.AddCommand(commitCmd.NewCommitCmd())
rootCmd.AddCommand(createPrsCmd.NewCreatePRsCmd())
Expand Down
16 changes: 16 additions & 0 deletions internal/executor/fake_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,19 @@ func NewAlternatingSuccessFakeExecutor() *FakeExecutor {
},
)
}

func NewAlwaysSucceedsAndReturnsTrueFakeExecutor() *FakeExecutor {
return NewFakeExecutor(func(s string, s2 string, s3 ...string) error {
return nil
}, func(s string, s2 string, s3 ...string) (string, error) {
return "true", nil
})
}

func NewAlwaysSucceedsAndReturnsFalseFakeExecutor() *FakeExecutor {
return NewFakeExecutor(func(s string, s2 string, s3 ...string) error {
return nil
}, func(s string, s2 string, s3 ...string) (string, error) {
return "false", nil
})
}
7 changes: 7 additions & 0 deletions internal/git/fake_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ func (f *FakeGit) Pull(output io.Writer, workingDir string, remote string, branc
return err
}

func (f *FakeGit) GetOriginUrl(output io.Writer, workingDir string) (string, error) {
call := []string{"remote", "get-url", "origin", workingDir}
f.calls = append(f.calls, call)
_, err := f.handler(output, call)
return "dummyUrl", err
}

func (f *FakeGit) AssertCalledWith(t *testing.T, expected [][]string) {
assert.Equal(t, expected, f.calls)
}
Expand Down
Loading
Loading