Skip to content

feat (cleanup forks): initial thoughts #168

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

Draft
wants to merge 3 commits 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
107 changes: 107 additions & 0 deletions cmd/cleanup/cleanup.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Only marking them for deletion if their PR is closed - would need to update the log message accordingly

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In discussion agreed to check more thoroughly for open PRs (not just from this campaign) using gh pr list --author username --state open

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()
}
}
1 change: 1 addition & 0 deletions cmd/cleanup/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package cleanup
15 changes: 15 additions & 0 deletions internal/github/fake_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const (
GetDefaultBranchName
UpdatePRDescription
IsPushable
IsFork
DeleteFork
)

type FakeGitHub struct {
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 22 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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", "isFork")
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{}
}
6 changes: 3 additions & 3 deletions internal/github/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`,
Expand All @@ -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"}`,
Expand All @@ -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
Expand Down
Loading