diff --git a/README.md b/README.md index 99b54e6..7cf0146 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It's dumb but it works. It doesn't scale well, though. Manually cloning and rais Turbolift essentially automates the boring parts and stays out of the way when it comes to actually making the changes. It automates cloning, committing, and raising PRs en-masse, so that you can focus on the substance of the change. -> Historical note: Turbolift supersedes an internal system at Skyscanner named Codelift. Codelift was a centralised batch system, requiring changes to be scripted upfront and run overnight. While Codelift was useful, we have found that a decentralised, interactive tool is far easier and quicker for people to use in practice. +> Historical note: Turbolift supersedes an internal system at Skyscanner named Codelift. Codelift was a centralised batch system, requiring changes to be scripted upfront and run overnight. While Codelift was useful, we have found that a decentralised, interactive tool is far easier and quicker for people to use in practice. ## Demo @@ -24,7 +24,7 @@ This demo shows Turbolift in action, creating a simple PR in two repositories: Pre-built binary archives can be downloaded from the [Releases](https://github.com/Skyscanner/turbolift/releases) page. -* Download, extract the archive, and move it onto your `PATH`. +* Download, extract the archive, and move it onto your `PATH`. * Note that the binaries are not currently notarized for MacOS Gatekeeper. If errors are displayed, use `xattr -c PATH_TO_TURBOLIFT_BINARY` to un-quarantine the binary, or right-click on the binary in Finder and choose 'Open' once to allow future execution. Distribution will be improved under https://github.com/Skyscanner/turbolift/issues/43. You must also have the GitHub CLI, `gh`, installed: @@ -99,8 +99,8 @@ You may wish to skip the fork and work on the upstream repository branch directl ### Making changes -Now, make changes to the checked-out repos under the `work` directory. -You can do this manually using an editor, using `sed` and similar commands, or using [`codemod`](https://github.com/facebook/codemod)/[`comby`](https://comby.dev/), etc. +Now, make changes to the checked-out repos under the `work` directory. +You can do this manually using an editor, using `sed` and similar commands, or using [`codemod`](https://github.com/facebook/codemod)/[`comby`](https://comby.dev/), etc. **You are free to use any tools that help get the job done.** @@ -123,8 +123,8 @@ When ready to commit changes across all repos, run: ```turbolift commit --message "Your commit message"``` -This command is a no-op on any repos that do not have any changes. -Note that the commit will be run with the `--all` flag set, meaning that it is not necessary to stage changes using `git add/rm` for changed files. +This command is a no-op on any repos that do not have any changes. +Note that the commit will be run with the `--all` flag set, meaning that it is not necessary to stage changes using `git add/rm` for changed files. Newly created files _will_ still need to be staged using `git add`. Repeat if you want to make multiple commits. @@ -152,6 +152,16 @@ For example: turbolift foreach gh pr close --delete-branch YOUR_USERNAME:CAMPAIGN_NAME ``` +### Updating PRs + +#### Closing all PRs + +To close all PRs currently opened under the campaign, there is a `--close` flag: + +```turbolift update-prs --close [--yes]``` + +If the flag `--yes` is not present, a confirmation prompt will be presented to the user. + ## Status: Preview This tool is fully functional, but we have improvements that we'd like to make, and would appreciate feedback. diff --git a/cmd/clone/clone_test.go b/cmd/clone/clone_test.go index fa508a7..f328f74 100644 --- a/cmd/clone/clone_test.go +++ b/cmd/clone/clone_test.go @@ -33,7 +33,7 @@ func TestItAbortsIfReposFileNotFound(t *testing.T) { fakeGit := git.NewAlwaysFailsFakeGit() g = fakeGit - testsupport.PrepareTempCampaign(false) + _ = testsupport.PrepareTempCampaign(false) err := os.Remove("repos.txt") if err != nil { panic(err) @@ -113,7 +113,6 @@ func TestItLogsCheckoutErrorsButContinuesToTryAll(t *testing.T) { {"checkout", "work/org/repo1", testsupport.Pwd()}, {"checkout", "work/org/repo2", testsupport.Pwd()}, }) - } func TestItClonesReposFoundInReposFile(t *testing.T) { @@ -220,7 +219,6 @@ func runCloneCommandWithFork() (string, error) { cmd.SetOut(outBuffer) nofork = false err := cmd.Execute() - if err != nil { return outBuffer.String(), err } diff --git a/cmd/create_prs/create_prs.go b/cmd/create_prs/create_prs.go index 5dff256..b5d30a4 100644 --- a/cmd/create_prs/create_prs.go +++ b/cmd/create_prs/create_prs.go @@ -16,22 +16,27 @@ package create_prs import ( + "os" + "path" + "time" + "github.com/skyscanner/turbolift/internal/campaign" "github.com/skyscanner/turbolift/internal/colors" "github.com/skyscanner/turbolift/internal/git" "github.com/skyscanner/turbolift/internal/github" "github.com/skyscanner/turbolift/internal/logging" "github.com/spf13/cobra" - "os" - "path" - "time" ) -var gh github.GitHub = github.NewRealGitHub() -var g git.Git = git.NewRealGit() +var ( + gh github.GitHub = github.NewRealGitHub() + g git.Git = git.NewRealGit() +) -var sleep time.Duration -var isDraft bool +var ( + sleep time.Duration + isDraft bool +) func NewCreatePRsCmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index 5ef5944..dc0795c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,19 +17,23 @@ package cmd import ( "fmt" + "log" + cloneCmd "github.com/skyscanner/turbolift/cmd/clone" commitCmd "github.com/skyscanner/turbolift/cmd/commit" createPrsCmd "github.com/skyscanner/turbolift/cmd/create_prs" "github.com/skyscanner/turbolift/cmd/flags" foreachCmd "github.com/skyscanner/turbolift/cmd/foreach" initCmd "github.com/skyscanner/turbolift/cmd/init" + updatePrsCmd "github.com/skyscanner/turbolift/cmd/update_prs" "github.com/spf13/cobra" - "log" ) -var version = "version-dev" -var commit = "commit-dev" -var date = "date-dev" +var ( + version = "version-dev" + commit = "commit-dev" + date = "date-dev" +) var rootCmd = &cobra.Command{ Use: "turbolift", @@ -47,6 +51,7 @@ func init() { rootCmd.AddCommand(createPrsCmd.NewCreatePRsCmd()) rootCmd.AddCommand(initCmd.NewInitCmd()) rootCmd.AddCommand(foreachCmd.NewForeachCmd()) + rootCmd.AddCommand(updatePrsCmd.NewUpdatePRsCmd()) } func Execute() { diff --git a/cmd/update_prs/update_prs.go b/cmd/update_prs/update_prs.go new file mode 100644 index 0000000..ca6dddb --- /dev/null +++ b/cmd/update_prs/update_prs.go @@ -0,0 +1,141 @@ +/* + * 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 + * http://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 update_prs + +import ( + "errors" + "fmt" + "os" + + "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/skyscanner/turbolift/internal/prompt" + "github.com/spf13/cobra" +) + +var ( + gh github.GitHub = github.NewRealGitHub() + p prompt.Prompt = prompt.NewRealPrompt() +) + +var ( + closeFlag bool + yesFlag bool +) + +func NewUpdatePRsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update-prs", + Short: "update all PRs that have been generated by the campaign", + Run: run, + } + + cmd.Flags().BoolVar(&closeFlag, "close", false, "Close all generated PRs") + cmd.Flags().BoolVar(&yesFlag, "yes", false, "Skips the confirmation prompt") + + return cmd +} + +// makes sure there is only one action activated +func onlyOne(args ...bool) bool { + // simple counter + b := map[bool]int{ + false: 0, + true: 0, + } + for _, v := range args { + b[v] += 1 + } + return b[true] == 1 +} + +func validateFlags(closeFlag bool) error { + // only option at the moment is `close` + if !onlyOne(closeFlag) { + return errors.New("update-prs needs one and only one action flag") + } + return nil +} + +// we keep the args as one of the subfunctions might need it one day. +func run(c *cobra.Command, args []string) { + logger := logging.NewLogger(c) + if err := validateFlags(closeFlag); err != nil { + logger.Errorf("Error while parsing the flags: %v", err) + return + } + + if closeFlag { + runClose(c, args) + } +} + +func runClose(c *cobra.Command, _ []string) { + logger := logging.NewLogger(c) + + readCampaignActivity := logger.StartActivity("Reading campaign data") + dir, err := campaign.OpenCampaign() + if err != nil { + readCampaignActivity.EndWithFailure(err) + return + } + readCampaignActivity.EndWithSuccess() + + // Prompting for confirmation + if !yesFlag { + // TODO: add the number of PRs that it will actually close + if !p.AskConfirm(fmt.Sprintf("Close all PRs from the %s campaign?", dir.Name)) { + return + } + } + + doneCount := 0 + skippedCount := 0 + errorCount := 0 + + for _, repo := range dir.Repos { + + closeActivity := logger.StartActivity("Closing PR in %s", repo.FullRepoName) + // skip if the working copy does not exist + if _, err = os.Stat(repo.FullRepoPath()); os.IsNotExist(err) { + closeActivity.EndWithWarningf("Directory %s does not exist - has it been cloned?", repo.FullRepoPath()) + skippedCount++ + continue + } + + err = gh.ClosePullRequest(closeActivity.Writer(), repo.FullRepoPath(), dir.Name) + if err != nil { + if _, ok := err.(*github.NoPRFoundError); ok { + closeActivity.EndWithWarning(err) + skippedCount++ + } else { + closeActivity.EndWithFailure(err) + errorCount++ + } + } else { + closeActivity.EndWithSuccess() + doneCount++ + } + } + + if errorCount == 0 { + logger.Successf("turbolift update-prs completed %s(%s, %s)\n", colors.Normal(), colors.Green(doneCount, " OK"), colors.Yellow(skippedCount, " skipped")) + } else { + logger.Warnf("turbolift update-prs completed with %s %s(%s, %s, %s)\n", colors.Red("errors"), colors.Normal(), colors.Green(doneCount, " OK"), colors.Yellow(skippedCount, " skipped"), colors.Red(errorCount, " errored")) + } +} diff --git a/cmd/update_prs/update_prs_test.go b/cmd/update_prs/update_prs_test.go new file mode 100644 index 0000000..9559f55 --- /dev/null +++ b/cmd/update_prs/update_prs_test.go @@ -0,0 +1,114 @@ +package update_prs + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/skyscanner/turbolift/internal/github" + "github.com/skyscanner/turbolift/internal/prompt" + "github.com/skyscanner/turbolift/internal/testsupport" + "github.com/stretchr/testify/assert" +) + +func TestItLogsClosePrErrorsButContinuesToTryAll(t *testing.T) { + fakeGitHub := github.NewAlwaysFailsFakeGitHub() + gh = fakeGitHub + + tempDir := testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCloseCommandAuto() + assert.NoError(t, err) + assert.Contains(t, out, "Closing PR in org/repo1") + assert.Contains(t, out, "Closing PR in org/repo2") + assert.Contains(t, out, "turbolift update-prs completed with errors") + assert.Contains(t, out, "2 errored") + + fakeGitHub.AssertCalledWith(t, [][]string{ + {"work/org/repo1", filepath.Base(tempDir)}, + {"work/org/repo2", filepath.Base(tempDir)}, + }) +} + +func TestItClosesPrsSuccessfully(t *testing.T) { + fakeGitHub := github.NewAlwaysSucceedsFakeGitHub() + gh = fakeGitHub + + tempDir := testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCloseCommandAuto() + assert.NoError(t, err) + assert.Contains(t, out, "Closing PR in org/repo1") + assert.Contains(t, out, "Closing PR in org/repo2") + assert.Contains(t, out, "turbolift update-prs completed") + assert.Contains(t, out, "2 OK") + assert.NotContains(t, out, "error") + + fakeGitHub.AssertCalledWith(t, [][]string{ + {"work/org/repo1", filepath.Base(tempDir)}, + {"work/org/repo2", filepath.Base(tempDir)}, + }) +} + +func TestNoPRFound(t *testing.T) { + fakeGitHub := github.NewAlwaysThrowNoPRFound() + gh = fakeGitHub + + tempDir := testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCloseCommandAuto() + assert.NoError(t, err) + assert.Contains(t, out, "no PR found for work/org/repo1 and branch "+filepath.Base(tempDir)) + assert.Contains(t, out, "no PR found for work/org/repo2 and branch "+filepath.Base(tempDir)) + assert.Contains(t, out, "turbolift update-prs completed") + assert.Contains(t, out, "0 OK, 2 skipped") + + fakeGitHub.AssertCalledWith(t, [][]string{ + {"work/org/repo1", filepath.Base(tempDir)}, + {"work/org/repo2", filepath.Base(tempDir)}, + }) +} + +func TestItDoesNotClosePRsIfNotConfirmed(t *testing.T) { + fakeGitHub := github.NewAlwaysSucceedsFakeGitHub() + gh = fakeGitHub + fakePrompt := prompt.NewFakePromptNo() + p = fakePrompt + + _ = testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2") + + out, err := runCloseCommandConfirm() + assert.NoError(t, err) + assert.NotContains(t, out, "Closing PR in org/repo1") + assert.NotContains(t, out, "Closing PR in org/repo2") + assert.NotContains(t, out, "turbolift update-prs completed") + assert.NotContains(t, out, "2 OK") + + fakeGitHub.AssertCalledWith(t, [][]string{}) +} + +func runCloseCommandAuto() (string, error) { + cmd := NewUpdatePRsCmd() + closeFlag = true + yesFlag = true + outBuffer := bytes.NewBufferString("") + cmd.SetOut(outBuffer) + err := cmd.Execute() + if err != nil { + return outBuffer.String(), err + } + return outBuffer.String(), nil +} + +func runCloseCommandConfirm() (string, error) { + cmd := NewUpdatePRsCmd() + closeFlag = true + yesFlag = false + outBuffer := bytes.NewBufferString("") + cmd.SetOut(outBuffer) + err := cmd.Execute() + if err != nil { + return outBuffer.String(), err + } + return outBuffer.String(), nil +} diff --git a/go.mod b/go.mod index b5d43f1..99c8e21 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.16 require ( github.com/briandowns/spinner v1.15.0 github.com/fatih/color v1.12.0 + github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-isatty v0.0.13 // indirect github.com/spf13/cobra v1.1.3 github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index ed6d296..8ae8b0f 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,10 @@ github.com/briandowns/spinner v1.12.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX github.com/briandowns/spinner v1.15.0 h1:L0jR0MYN7OAeMwpTzDZWIeqyDLXtTeJFxqoq+sL0VQM= github.com/briandowns/spinner v1.15.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -113,6 +117,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -240,6 +246,7 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/campaign/campaign.go b/internal/campaign/campaign.go index 29cf238..0e18300 100644 --- a/internal/campaign/campaign.go +++ b/internal/campaign/campaign.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" ) @@ -38,6 +39,10 @@ type Campaign struct { PrBody string } +func (r Repo) FullRepoPath() string { + return path.Join("work", r.OrgName, r.RepoName) // i.e. work/org/repo +} + func OpenCampaign() (*Campaign, error) { dir, _ := os.Getwd() dirBasename := filepath.Base(dir) diff --git a/internal/github/fake_github.go b/internal/github/fake_github.go index 5bccd62..9a7a634 100644 --- a/internal/github/fake_github.go +++ b/internal/github/fake_github.go @@ -17,9 +17,10 @@ package github import ( "errors" - "github.com/stretchr/testify/assert" "io" "testing" + + "github.com/stretchr/testify/assert" ) type FakeGitHub struct { @@ -44,6 +45,14 @@ func (f *FakeGitHub) Clone(output io.Writer, workingDir string, fullRepoName str return err } +func (f *FakeGitHub) ClosePullRequest(output io.Writer, workingDir string, branchName string) error { + // TODO: handle this differently; branchName here is replacing fullRepoName + // This is OK for now because fullRepoName is used nowhere in the github mocks + f.calls = append(f.calls, []string{workingDir, branchName}) + _, err := f.handler(output, workingDir, branchName) + return err +} + func (f *FakeGitHub) AssertCalledWith(t *testing.T, expected [][]string) { assert.Equal(t, expected, f.calls) } @@ -67,6 +76,12 @@ func NewAlwaysFailsFakeGitHub() *FakeGitHub { }) } +func NewAlwaysThrowNoPRFound() *FakeGitHub { + return NewFakeGitHub(func(output io.Writer, workingDir string, branchName string) (bool, error) { + return false, &NoPRFoundError{Path: workingDir, BranchName: branchName} + }) +} + func NewAlwaysReturnsFalseFakeGitHub() *FakeGitHub { return NewFakeGitHub(func(output io.Writer, workingDir string, fullRepoName string) (bool, error) { return false, nil diff --git a/internal/github/github.go b/internal/github/github.go index 66b5994..9b179fa 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -16,6 +16,8 @@ package github import ( + "encoding/json" + "fmt" "io" "strings" @@ -35,10 +37,10 @@ type GitHub interface { ForkAndClone(output io.Writer, workingDir string, fullRepoName string) error Clone(output io.Writer, workingDir string, fullRepoName string) error CreatePullRequest(output io.Writer, workingDir string, metadata PullRequest) (didCreate bool, err error) + ClosePullRequest(output io.Writer, workingDir string, branchName string) error } -type RealGitHub struct { -} +type RealGitHub struct{} func (r *RealGitHub) CreatePullRequest(output io.Writer, workingDir string, pr PullRequest) (didCreate bool, err error) { gh_args := []string{ @@ -63,7 +65,6 @@ func (r *RealGitHub) CreatePullRequest(output io.Writer, workingDir string, pr P } else if err != nil { return false, err } - return true, nil } @@ -75,6 +76,82 @@ func (r *RealGitHub) Clone(output io.Writer, workingDir string, fullRepoName str return execInstance.Execute(output, workingDir, "gh", "repo", "clone", fullRepoName) } +func (r *RealGitHub) ClosePullRequest(output io.Writer, workingDir string, branchName string) error { + pr, err := r.GetPR(output, workingDir, branchName) + if err != nil { + return err + } + + return execInstance.Execute(output, workingDir, "gh", "pr", "close", fmt.Sprint(pr.Number)) +} + +// the following is used internally to retrieve PRs from a given repository +// using `gh pr status` + +type PrStatusResponse struct { // https://github.com/cli/cli/blob/4b415f80d79e57eda48bb67b30cfb53d18b7cba7/pkg/cmd/pr/status/status.go#L114-L118 + CurrentBranch *PrStatus `json:"currentBranch"` + CreatedBy []*PrStatus `json:"createdBy"` + NeedsReview []*PrStatus `json:"needsReview"` +} + +type PrStatus struct { + Closed bool `json:"closed"` + HeadRefName string `json:"headRefName"` + Mergeable string `json:"mergeable"` + Number int `json:"number"` + ReactionGroups []ReactionGroup `json:"reactionGroups"` + ReviewDecision string `json:"reviewDecision"` + State string `json:"state"` + Title string `json:"title"` + Url string `json:"url"` +} + +type ReactionGroupUsers struct { + TotalCount int +} + +type ReactionGroup struct { + Content string + Users ReactionGroupUsers +} + +// GetPR is a helper function to retrieve the PR associated with the branch Name +type NoPRFoundError struct { + Path string + BranchName string +} + +func (e *NoPRFoundError) Error() string { + return fmt.Sprintf("no PR found for %s and branch %s", e.Path, e.BranchName) +} + +func (r *RealGitHub) GetPR(output io.Writer, workingDir string, branchName string) (*PrStatus, error) { + s, err := execInstance.ExecuteAndCapture(output, workingDir, "gh", "pr", "status", "--json", "closed,headRefName,mergeable,number,reactionGroups,reviewDecision,state,title,url") + if err != nil { + return nil, err + } + + var prr PrStatusResponse + if err := json.Unmarshal([]byte(s), &prr); err != nil { + return nil, fmt.Errorf("unable to unmarshall the Pr Status Output: %w", err) + } + + // if the user has write permissions on the repo, + // the PR should be under _CurrentBranch_. + if prr.CurrentBranch != nil && !prr.CurrentBranch.Closed { + return prr.CurrentBranch, nil + } + + // If not, then it's a forked PR. The headRefName is as such: `username:branchName` + for _, pr := range prr.CreatedBy { + if strings.HasSuffix(pr.HeadRefName, branchName) && !pr.Closed { + return pr, nil + } + } + + return nil, &NoPRFoundError{Path: workingDir, BranchName: branchName} +} + func NewRealGitHub() *RealGitHub { return &RealGitHub{} } diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 5ebb7e5..3edcbc8 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -17,12 +17,13 @@ package logging import ( "fmt" + "io" + "time" + "github.com/briandowns/spinner" "github.com/skyscanner/turbolift/cmd/flags" "github.com/skyscanner/turbolift/internal/colors" "github.com/spf13/cobra" - "io" - "time" ) // Logger is a facade for CLI logging. @@ -59,6 +60,11 @@ func (log *Logger) Warnf(format string, args ...interface{}) { log.Printf(prefixedFormat, args...) } +func (log *Logger) Errorf(format string, args ...interface{}) { + prefixedFormat := fmt.Sprint(colors.Warn(" ERR "), " ", colors.Red(format)) + log.Printf(prefixedFormat, args...) +} + // StartActivity creates and starts an *Activity with an associated spinner. // Only once Activity should be active at any given time, and the Activity should be completed before any other logging // is performed using this Logger. diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..d9f1876 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,60 @@ +package prompt + +import ( + "strings" + + "github.com/manifoldco/promptui" +) + +type Prompt interface { + AskConfirm(string) bool +} + +type RealPrompt struct{} + +// NewRealPrompt is the factory builder for RealPrompt +func NewRealPrompt() *RealPrompt { + return &RealPrompt{} +} + +// AskConfirm will use promptui to provide a confirmation +func (r *RealPrompt) AskConfirm(confirm string) bool { + p := promptui.Prompt{ + Label: confirm, + IsConfirm: true, + } + if res, err := p.Run(); err != nil { + return false + } else { + switch strings.ToLower(res) { + case "y": + return true + case "yes": + return true + default: + return false + } + } +} + +// Mock Prompt that always returns true +type FakePromptYes struct{} + +func NewFakePromptYes() *FakePromptYes { + return &FakePromptYes{} +} + +func (f FakePromptYes) AskConfirm(_ string) bool { + return true +} + +// Mock Prompt that always returns false +type FakePromptNo struct{} + +func NewFakePromptNo() *FakePromptNo { + return &FakePromptNo{} +} + +func (f FakePromptNo) AskConfirm(_ string) bool { + return false +} diff --git a/internal/testsupport/testsupport.go b/internal/testsupport/testsupport.go index 9096bc2..81ceccd 100644 --- a/internal/testsupport/testsupport.go +++ b/internal/testsupport/testsupport.go @@ -28,17 +28,17 @@ func Pwd() string { return filepath.Base(dir) } -func CreateAndEnterTempDirectory() { +func CreateAndEnterTempDirectory() string { tempDir, _ := ioutil.TempDir("", "turbolift-test-*") err := os.Chdir(tempDir) - if err != nil { panic(err) } + return tempDir } -func PrepareTempCampaign(createDirs bool, repos ...string) { - CreateAndEnterTempDirectory() +func PrepareTempCampaign(createDirs bool, repos ...string) string { + tempDir := CreateAndEnterTempDirectory() delimitedList := strings.Join(repos, "\n") err := ioutil.WriteFile("repos.txt", []byte(delimitedList), os.ModePerm|0644) @@ -61,4 +61,6 @@ func PrepareTempCampaign(createDirs bool, repos ...string) { if err != nil { panic(err) } + + return tempDir }