From 4006fb4b914643495398163cdc6c69f8770fdb15 Mon Sep 17 00:00:00 2001 From: Danny Ranson Date: Tue, 1 Apr 2025 17:50:36 +0100 Subject: [PATCH 1/3] initial thought --- cmd/cleanup/cleanup.go | 107 ++++++++++++++++++++++++++++++++++++ cmd/cleanup/cleanup_test.go | 1 + internal/github/github.go | 22 ++++++++ 3 files changed, 130 insertions(+) create mode 100644 cmd/cleanup/cleanup.go create mode 100644 cmd/cleanup/cleanup_test.go diff --git a/cmd/cleanup/cleanup.go b/cmd/cleanup/cleanup.go new file mode 100644 index 0000000..8669d14 --- /dev/null +++ b/cmd/cleanup/cleanup.go @@ -0,0 +1,107 @@ +package cleanup + +import ( + "bufio" + "github.com/skyscanner/turbolift/internal/campaign" + "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" + apply bool + repoFile string +) + +func NewCleanupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Cleans up forks used in this campaign", + Run: run, + } + + cmd.Flags().BoolVar(&apply, "apply", false, "Delete unused forks rather than just listing them") + cmd.Flags().StringVar(&repoFile, "repos", "repos.txt", "A file containing a list of repositories to cleanup.") + + return cmd +} + +func run(c *cobra.Command, _ []string) { + if apply { + logger := logging.NewLogger(c) + if _, err := os.Stat(cleanupFile); os.IsNotExist(err) { + logger.Errorf("The file %s does not exist. Please run `turbolift cleanup` without the --apply flag first.", cleanupFile) + } + readFileActivity := logger.StartActivity("Reading cleanup file") + cleanupContents, err := os.Open(cleanupFile) + if err != nil { + readFileActivity.EndWithFailure(err) + return + } + defer func(reposToDelete *os.File) { + err := reposToDelete.Close() + if err != nil { + readFileActivity.EndWithFailure(err) + } + }(cleanupContents) + scanner := bufio.NewScanner(cleanupContents) + for scanner.Scan() { + err = gh.DeleteFork(logger.Writer(), scanner.Text()) + if err != nil { + readFileActivity.EndWithFailure(err) + return + } + } + } else { + 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() + + deletableForksActivity := logger.StartActivity("Checking for deletable forks") + deletableForks, err := os.Create(cleanupFile) + if err != nil { + deletableForksActivity.EndWithFailure(err) + return + } + defer func(deletableForks *os.File) { + err := deletableForks.Close() + if err != nil { + deletableForksActivity.EndWithFailure(err) + } + }(deletableForks) + var doneCount, errorCount int + for _, repo := range dir.Repos { + isFork, err := gh.IsFork(logger.Writer(), repo.FullRepoName) + if err != nil { + deletableForksActivity.EndWithFailure(err) + errorCount++ + continue + } + repoDirPath := path.Join("work", repo.OrgName, repo.RepoName) + pr, err := gh.GetPR(logger.Writer(), repoDirPath, dir.Name) + if err != nil { + deletableForksActivity.EndWithFailure(err) + errorCount++ + continue + } + prClosed := pr.Closed == true + if isFork && prClosed { + deletableForks.WriteString(repo.FullRepoName + "\n") + } + doneCount++ + } + logger.Printf("A list of forks used in this campaign has been written to %s. Check these carefully and run `turbolift cleanup --apply` in order to delete them.", cleanupFile) + deletableForksActivity.EndWithSuccess() + } +} diff --git a/cmd/cleanup/cleanup_test.go b/cmd/cleanup/cleanup_test.go new file mode 100644 index 0000000..0c17920 --- /dev/null +++ b/cmd/cleanup/cleanup_test.go @@ -0,0 +1 @@ +package cleanup diff --git a/internal/github/github.go b/internal/github/github.go index 898c390..1c4fd3a 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -44,6 +44,8 @@ type GitHub interface { GetPR(output io.Writer, workingDir string, branchName string) (*PrStatus, error) GetDefaultBranchName(output io.Writer, workingDir string, fullRepoName string) (string, error) IsPushable(output io.Writer, repo string) (bool, error) + IsFork(output io.Writer, repo string) (bool, error) + DeleteFork(output io.Writer, repo string) error } type RealGitHub struct{} @@ -187,6 +189,26 @@ func (r *RealGitHub) IsPushable(output io.Writer, repo string) (bool, error) { return userHasPushPermission(s) } +func (r *RealGitHub) IsFork(output io.Writer, repo string) (bool, error) { + currentDir, err := os.Getwd() + if err != nil { + return false, err + } + response, err := execInstance.ExecuteAndCapture(output, currentDir, "gh", "repo", "view", repo, "--json", "response") + if err != nil { + return false, err + } + if strings.Contains(response, "true") { + return true, err + } else { + return false, err + } +} + +func (r *RealGitHub) DeleteFork(output io.Writer, repo string) error { + return execInstance.Execute(output, "gh", "repo", "--delete", repo, "--yes") +} + func NewRealGitHub() *RealGitHub { return &RealGitHub{} } From 190cc07e99413d181f65b8765f22776764bf593d Mon Sep 17 00:00:00 2001 From: Danny Ranson Date: Mon, 14 Apr 2025 15:04:25 +0100 Subject: [PATCH 2/3] update to prevent tests from complaining --- internal/github/fake_github.go | 15 +++++++++++++++ internal/github/util_test.go | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/github/fake_github.go b/internal/github/fake_github.go index a0c7925..1e322a4 100644 --- a/internal/github/fake_github.go +++ b/internal/github/fake_github.go @@ -33,6 +33,8 @@ const ( GetDefaultBranchName UpdatePRDescription IsPushable + IsFork + DeleteFork ) type FakeGitHub struct { @@ -97,6 +99,19 @@ func (f *FakeGitHub) UpdatePRDescription(_ io.Writer, workingDir string, title s return err } +func (f *FakeGitHub) IsFork(_ io.Writer, repo string) (bool, error) { + args := []string{"is_fork", repo} + f.calls = append(f.calls, args) + return f.handler(IsFork, args) +} + +func (f *FakeGitHub) DeleteFork(_ io.Writer, repo string) error { + args := []string{"delete_fork", repo} + f.calls = append(f.calls, args) + _, err := f.handler(DeleteFork, args) + return err +} + func (f *FakeGitHub) AssertCalledWith(t *testing.T, expected [][]string) { assert.Equal(t, expected, f.calls) } diff --git a/internal/github/util_test.go b/internal/github/util_test.go index 855cad9..7ee84bd 100644 --- a/internal/github/util_test.go +++ b/internal/github/util_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestuserHasPushPermissionReturnsTrueForAllCases(t *testing.T) { +func TestUserHasPushPermissionReturnsTrueForAllCases(t *testing.T) { testCases := []string{ `{"viewerPermission":"WRITE"}`, `{"viewerPermission":"MAINTAIN"}`, @@ -35,7 +35,7 @@ func TestuserHasPushPermissionReturnsTrueForAllCases(t *testing.T) { } } -func TestuserHasPushPermissionReturnsFalseForUnknownPermission(t *testing.T) { +func TestUserHasPushPermissionReturnsFalseForUnknownPermission(t *testing.T) { testCases := []string{ `{"viewerPermission":"UNKNOWN"}`, `{"viewerPermission":"READ"}`, @@ -51,7 +51,7 @@ func TestuserHasPushPermissionReturnsFalseForUnknownPermission(t *testing.T) { } } -func TestuserHasPushPermissionReturnsErrorForInvalidJSON(t *testing.T) { +func TestUserHasPushPermissionReturnsErrorForInvalidJSON(t *testing.T) { testCases := []string{ `{"viewerPermission":"WRITE"`, // invalid JSON `viewerPermission: WRITE`, // invalid JSON From 560039e07f917a0e16604a5b26e10cb04da14d52 Mon Sep 17 00:00:00 2001 From: Danny Ranson Date: Mon, 14 Apr 2025 15:07:44 +0100 Subject: [PATCH 3/3] correct typo --- internal/github/github.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/github/github.go b/internal/github/github.go index 1c4fd3a..59a68d3 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -194,7 +194,7 @@ func (r *RealGitHub) IsFork(output io.Writer, repo string) (bool, error) { if err != nil { return false, err } - response, err := execInstance.ExecuteAndCapture(output, currentDir, "gh", "repo", "view", repo, "--json", "response") + response, err := execInstance.ExecuteAndCapture(output, currentDir, "gh", "repo", "view", repo, "--json", "isFork") if err != nil { return false, err }