diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f738eba45..d230d3d07 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,10 +9,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Set up Go 1.14 + - name: Set up Go 1.15 uses: actions/setup-go@v2 with: - go-version: 1.14 + go-version: 1.15 - name: Check out code uses: actions/checkout@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ca2f61ae9..4dc95a4f1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.14 + - name: Set up Go 1.15 uses: actions/setup-go@v2 with: - go-version: 1.14 + go-version: 1.15 - name: Check out code uses: actions/checkout@v2 diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 1654e74df..1c3baa6e8 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -11,10 +11,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 - - name: Set up Go 1.14 + - name: Set up Go 1.15 uses: actions/setup-go@v2 with: - go-version: 1.14 + go-version: 1.15 - name: Generate changelog run: | echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/} diff --git a/README.md b/README.md index 68a7e7a16..7f5db9700 100644 --- a/README.md +++ b/README.md @@ -113,43 +113,53 @@ MSI installers are available for download on the [releases page][]. Install and upgrade: -1. Download the `.deb` file from the [releases page][] -2. `sudo apt install ./gh_*_linux_amd64.deb` install the downloaded file +1. Download the `.deb` file from the [releases page][]; +2. Install the downloaded file: `sudo apt install ./gh_*_linux_amd64.deb` ### Fedora Linux Install and upgrade: -1. Download the `.rpm` file from the [releases page][] -2. `sudo dnf install gh_*_linux_amd64.rpm` install the downloaded file +1. Download the `.rpm` file from the [releases page][]; +2. Install the downloaded file: `sudo dnf install gh_*_linux_amd64.rpm` ### Centos Linux Install and upgrade: -1. Download the `.rpm` file from the [releases page][] -2. `sudo yum localinstall gh_*_linux_amd64.rpm` install the downloaded file +1. Download the `.rpm` file from the [releases page][]; +2. Install the downloaded file: `sudo yum localinstall gh_*_linux_amd64.rpm` ### openSUSE/SUSE Linux Install and upgrade: -1. Download the `.rpm` file from the [releases page][] -2. `sudo zypper in gh_*_linux_amd64.rpm` install the downloaded file +1. Download the `.rpm` file from the [releases page][]; +2. Install the downloaded file: `sudo zypper in gh_*_linux_amd64.rpm` ### Arch Linux -Arch Linux users can install from the community repo: https://www.archlinux.org/packages/community/x86_64/github-cli/ +Arch Linux users can install from the [community repo](https://www.archlinux.org/packages/community/x86_64/github-cli/): ```bash pacman -S github-cli ``` +### Android + +Android users can install via Termux: + +```bash +pkg install gh +``` + ### Other platforms -Install a prebuilt binary from the [releases page][] +Download packaged binaries from the [releases page][]. -### [Build from source](/docs/source.md) +### Build from source + +See here on how to [build GitHub CLI from source](/docs/source.md). [docs]: https://cli.github.com/manual [scoop]: https://scoop.sh diff --git a/api/client.go b/api/client.go index 36abbfbf8..2556f6317 100644 --- a/api/client.go +++ b/api/client.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -195,19 +196,22 @@ func (err HTTPError) Error() string { return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) } -// Returns whether or not scopes are present, appID, and error -func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) { - url := "https://api.github.com/user" - req, err := http.NewRequest("GET", url, nil) +type MissingScopesError struct { + error +} + +func (c Client) HasMinimumScopes(hostname string) error { + apiEndpoint := ghinstance.RESTPrefix(hostname) + + req, err := http.NewRequest("GET", apiEndpoint, nil) if err != nil { - return false, "", err + return err } req.Header.Set("Content-Type", "application/json; charset=utf-8") - res, err := c.http.Do(req) if err != nil { - return false, "", err + return err } defer func() { @@ -218,26 +222,35 @@ func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) { }() if res.StatusCode != 200 { - return false, "", handleHTTPError(res) + return handleHTTPError(res) } - appID := res.Header.Get("X-Oauth-Client-Id") hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",") - found := 0 + search := map[string]bool{ + "repo": false, + "read:org": false, + "admin:org": false, + } + for _, s := range hasScopes { - for _, w := range wantedScopes { - if w == strings.TrimSpace(s) { - found++ - } - } + search[strings.TrimSpace(s)] = true } - if found == len(wantedScopes) { - return true, appID, nil + errorMsgs := []string{} + if !search["repo"] { + errorMsgs = append(errorMsgs, "missing required scope 'repo'") } - return false, appID, nil + if !search["read:org"] && !search["admin:org"] { + errorMsgs = append(errorMsgs, "missing required scope 'read:org'") + } + + if len(errorMsgs) > 0 { + return &MissingScopesError{error: errors.New(strings.Join(errorMsgs, ";"))} + } + + return nil } // GraphQL performs a GraphQL request and parses the response diff --git a/api/queries_pr.go b/api/queries_pr.go index b2fade857..f2b398adf 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -8,9 +8,9 @@ import ( "net/http" "strings" - "github.com/shurcooL/githubv4" - + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type PullRequestReviewState int @@ -210,8 +210,8 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { } func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d", - ghrepo.FullName(baseRepo), prNumber) + url := fmt.Sprintf("%srepos/%s/pulls/%d", + ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 92581d07a..631185393 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -5,7 +5,9 @@ import ( "os" "strings" - "github.com/cli/cli/command" + "github.com/cli/cli/pkg/cmd/root" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" "github.com/spf13/cobra/doc" "github.com/spf13/pflag" ) @@ -35,13 +37,16 @@ func main() { fatal("no dir set") } + io, _, _, _ := iostreams.Test() + rootCmd := root.NewCmdRoot(&cmdutil.Factory{IOStreams: io}, "", "") + err := os.MkdirAll(*dir, 0755) if err != nil { fatal(err) } if *website { - err = doc.GenMarkdownTreeCustom(command.RootCmd, *dir, filePrepender, linkHandler) + err = doc.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler) if err != nil { fatal(err) } @@ -54,7 +59,7 @@ func main() { Source: "", //source and manual are just put at the top of the manpage, before name Manual: "", //if source is an empty string, it's set to "Auto generated by spf13/cobra" } - err = doc.GenManTree(command.RootCmd, header, *dir) + err = doc.GenManTree(rootCmd, header, *dir) if err != nil { fatal(err) } diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 5336033fd..6870c9079 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -12,6 +12,11 @@ import ( "github.com/cli/cli/command" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/alias/expand" + "github.com/cli/cli/pkg/cmd/factory" + "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/update" "github.com/cli/cli/utils" @@ -31,25 +36,48 @@ func main() { hasDebug := os.Getenv("DEBUG") != "" - stderr := utils.NewColorable(os.Stderr) + if hostFromEnv := os.Getenv("GH_HOST"); hostFromEnv != "" { + ghinstance.OverrideDefault(hostFromEnv) + } + + cmdFactory := factory.New(command.Version) + stderr := cmdFactory.IOStreams.ErrOut + rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate) expandedArgs := []string{} if len(os.Args) > 0 { expandedArgs = os.Args[1:] } - cmd, _, err := command.RootCmd.Traverse(expandedArgs) - if err != nil || cmd == command.RootCmd { + cmd, _, err := rootCmd.Traverse(expandedArgs) + if err != nil || cmd == rootCmd { originalArgs := expandedArgs isShell := false - expandedArgs, isShell, err = command.ExpandAlias(os.Args) + + cfg, err := cmdFactory.Config() + if err != nil { + fmt.Fprintf(stderr, "failed to read configuration: %s\n", err) + os.Exit(2) + } + + expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil) if err != nil { fmt.Fprintf(stderr, "failed to process aliases: %s\n", err) os.Exit(2) } + if hasDebug { + fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs) + } + if isShell { - err = command.ExecuteShellAlias(expandedArgs) + externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...) + externalCmd.Stderr = os.Stderr + externalCmd.Stdout = os.Stdout + externalCmd.Stdin = os.Stdin + preparedCmd := run.PrepareCmd(externalCmd) + + err = preparedCmd.Run() if err != nil { if ee, ok := err.(*exec.ExitError); ok { os.Exit(ee.ExitCode()) @@ -61,19 +89,15 @@ func main() { os.Exit(0) } - - if hasDebug { - fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs) - } } - command.RootCmd.SetArgs(expandedArgs) + rootCmd.SetArgs(expandedArgs) - if cmd, err := command.RootCmd.ExecuteC(); err != nil { - printError(os.Stderr, err, cmd, hasDebug) + if cmd, err := rootCmd.ExecuteC(); err != nil { + printError(stderr, err, cmd, hasDebug) os.Exit(1) } - if command.HasFailed() { + if root.HasFailed() { os.Exit(1) } @@ -85,7 +109,6 @@ func main() { ansi.Color(newRelease.Version, "cyan"), ansi.Color(newRelease.URL, "yellow")) - stderr := utils.NewColorable(os.Stderr) fmt.Fprintf(stderr, "\n\n%s\n\n", msg) } } diff --git a/command/alias.go b/command/alias.go deleted file mode 100644 index 7f02b03d5..000000000 --- a/command/alias.go +++ /dev/null @@ -1,233 +0,0 @@ -package command - -import ( - "fmt" - "sort" - "strings" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/utils" - "github.com/google/shlex" - "github.com/spf13/cobra" -) - -func init() { - RootCmd.AddCommand(aliasCmd) - aliasCmd.AddCommand(aliasSetCmd) - aliasCmd.AddCommand(aliasListCmd) - aliasCmd.AddCommand(aliasDeleteCmd) - - aliasSetCmd.Flags().BoolP("shell", "s", false, "Declare an alias to be passed through a shell interpreter") -} - -var aliasCmd = &cobra.Command{ - Use: "alias", - Short: "Create command shortcuts", - Long: heredoc.Doc(` - Aliases can be used to make shortcuts for gh commands or to compose multiple commands. - - Run "gh help alias set" to learn more. - `), -} - -var aliasSetCmd = &cobra.Command{ - Use: "set ", - Short: "Create a shortcut for a gh command", - Long: heredoc.Doc(` - Declare a word as a command alias that will expand to the specified command(s). - - The expansion may specify additional arguments and flags. If the expansion - includes positional placeholders such as '$1', '$2', etc., any extra arguments - that follow the invocation of an alias will be inserted appropriately. - - If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you - to compose commands with "|" or redirect with ">". Note that extra arguments following the alias - will not be automatically passed to the expanded expression. To have a shell alias receive - arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them. - - Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If - you have installed git on Windows in some other way, shell aliases may not work for you. - - Quotes must always be used when defining a command as in the examples.`), - Example: heredoc.Doc(` - $ gh alias set pv 'pr view' - $ gh pv -w 123 - #=> gh pr view -w 123 - - $ gh alias set bugs 'issue list --label="bugs"' - - $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' - $ gh epicsBy vilmibm - #=> gh issue list --author="vilmibm" --label="epic" - - $ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2' - $ gh igrep epic foo - #=> gh issue list --label="epic" | grep "foo"`), - Args: cobra.ExactArgs(2), - RunE: aliasSet, -} - -func aliasSet(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return err - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return err - } - - alias := args[0] - expansion := args[1] - - stderr := colorableErr(cmd) - if connectedToTerminal(cmd) { - fmt.Fprintf(stderr, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansion)) - } - - shell, err := cmd.Flags().GetBool("shell") - if err != nil { - return err - } - if shell && !strings.HasPrefix(expansion, "!") { - expansion = "!" + expansion - } - isExternal := strings.HasPrefix(expansion, "!") - - if validCommand(alias) { - return fmt.Errorf("could not create alias: %q is already a gh command", alias) - } - - if !isExternal && !validCommand(expansion) { - return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) - } - - successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) - - oldExpansion, ok := aliasCfg.Get(alias) - if ok { - successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", - utils.Green("✓"), - utils.Bold(alias), - utils.Bold(oldExpansion), - utils.Bold(expansion), - ) - } - - err = aliasCfg.Add(alias, expansion) - if err != nil { - return fmt.Errorf("could not create alias: %s", err) - } - - if connectedToTerminal(cmd) { - fmt.Fprintln(stderr, successMsg) - } - - return nil -} - -func validCommand(expansion string) bool { - split, err := shlex.Split(expansion) - if err != nil { - return false - } - cmd, _, err := RootCmd.Traverse(split) - return err == nil && cmd != RootCmd -} - -var aliasListCmd = &cobra.Command{ - Use: "list", - Short: "List your aliases", - Long: `This command prints out all of the aliases gh is configured to use.`, - Args: cobra.ExactArgs(0), - RunE: aliasList, -} - -func aliasList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return fmt.Errorf("couldn't read config: %w", err) - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return fmt.Errorf("couldn't read aliases config: %w", err) - } - - stderr := colorableErr(cmd) - - if aliasCfg.Empty() { - if connectedToTerminal(cmd) { - fmt.Fprintf(stderr, "no aliases configured\n") - } - return nil - } - - stdout := colorableOut(cmd) - - tp := utils.NewTablePrinter(stdout) - - aliasMap := aliasCfg.All() - keys := []string{} - for alias := range aliasMap { - keys = append(keys, alias) - } - sort.Strings(keys) - - for _, alias := range keys { - if tp.IsTTY() { - // ensure that screen readers pause - tp.AddField(alias+":", nil, nil) - } else { - tp.AddField(alias, nil, nil) - } - tp.AddField(aliasMap[alias], nil, nil) - tp.EndRow() - } - - return tp.Render() -} - -var aliasDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an alias.", - Args: cobra.ExactArgs(1), - RunE: aliasDelete, -} - -func aliasDelete(cmd *cobra.Command, args []string) error { - alias := args[0] - - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return fmt.Errorf("couldn't read config: %w", err) - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return fmt.Errorf("couldn't read aliases config: %w", err) - } - - expansion, ok := aliasCfg.Get(alias) - if !ok { - return fmt.Errorf("no such alias %s", alias) - - } - - err = aliasCfg.Delete(alias) - if err != nil { - return fmt.Errorf("failed to delete alias %s: %w", alias, err) - } - - if connectedToTerminal(cmd) { - stderr := colorableErr(cmd) - redCheck := utils.Red("✓") - fmt.Fprintf(stderr, "%s Deleted alias %s; was %s\n", redCheck, alias, expansion) - } - - return nil -} diff --git a/command/alias_test.go b/command/alias_test.go deleted file mode 100644 index 63646f7a6..000000000 --- a/command/alias_test.go +++ /dev/null @@ -1,423 +0,0 @@ -package command - -import ( - "bytes" - "strings" - "testing" - - "github.com/cli/cli/internal/config" - "github.com/cli/cli/test" - "github.com/stretchr/testify/assert" -) - -func stubSh(value string) func() { - orig := findSh - findSh = func() (string, error) { - return value, nil - } - return func() { - findSh = orig - } -} - -func TestAliasSet_gh_command(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - _, err := RunCommand("alias set pr 'pr status'") - if err == nil { - t.Fatal("expected error") - } - - eq(t, err.Error(), `could not create alias: "pr" is already a gh command`) -} - -func TestAliasSet_empty_aliases(t *testing.T) { - cfg := `--- -aliases: -editor: vim -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set co 'pr checkout'") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Added alias") - test.ExpectLines(t, output.String(), "") - - expected := `aliases: - co: pr checkout -editor: vim -` - eq(t, mainBuf.String(), expected) -} - -func TestAliasSet_existing_alias(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: token123 -aliases: - co: pr checkout -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set co 'pr checkout -Rcool/repo'") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo") -} - -func TestAliasSet_space_args(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand(`alias set il 'issue list -l "cool story"'`) - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`) - - test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`) -} - -func TestAliasSet_arg_processing(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - defer stubTerminal(true)() - cases := []struct { - Cmd string - ExpectedOutputLine string - ExpectedConfigLine string - }{ - {`alias set il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"}, - - {`alias set iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"}, - - {`alias set ii 'issue list --author="$1" --label="$2"'`, - `- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`, - `ii: issue list --author="\$1" --label="\$2"`}, - - {`alias set ix "issue list --author='\$1' --label='\$2'"`, - `- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`, - `ix: issue list --author='\$1' --label='\$2'`}, - } - - for _, c := range cases { - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand(c.Cmd) - if err != nil { - t.Fatalf("got unexpected error running %s: %s", c.Cmd, err) - } - - test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine) - test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine) - } -} - -func TestAliasSet_init_alias_cfg(t *testing.T) { - cfg := `--- -editor: vim -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set diff 'pr diff'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `editor: vim -aliases: - diff: pr diff -` - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.") - eq(t, mainBuf.String(), expected) -} - -func TestAliasSet_existing_aliases(t *testing.T) { - cfg := `--- -aliases: - foo: bar -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set view 'pr view'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `aliases: - foo: bar - view: pr view -` - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.") - eq(t, mainBuf.String(), expected) - -} - -func TestExpandAlias_shell(t *testing.T) { - defer stubSh("sh")() - cfg := `--- -aliases: - ig: '!gh issue list | grep cool' -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - expanded, isShell, err := ExpandAlias([]string{"gh", "ig"}) - - assert.True(t, isShell) - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - expected := []string{"sh", "-c", "gh issue list | grep cool"} - - assert.Equal(t, expected, expanded) -} - -func TestExpandAlias_shell_extra_args(t *testing.T) { - defer stubSh("sh")() - cfg := `--- -aliases: - ig: '!gh issue list --label=$1 | grep' -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - expanded, isShell, err := ExpandAlias([]string{"gh", "ig", "bug", "foo"}) - - assert.True(t, isShell) - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - expected := []string{"sh", "-c", "gh issue list --label=$1 | grep", "--", "bug", "foo"} - - assert.Equal(t, expected, expanded) -} - -func TestExpandAlias(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - for _, c := range []struct { - Args string - ExpectedArgs []string - Err string - }{ - {"gh co", []string{"pr", "checkout"}, ""}, - {"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`}, - {"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`}, - {"gh co 123", []string{"pr", "checkout", "123"}, ""}, - {"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""}, - {"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""}, - {"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""}, - {"gh pr status", []string{"pr", "status"}, ""}, - {"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""}, - {"gh dne", []string{"dne"}, ""}, - {"gh", []string{}, ""}, - {"", []string{}, ""}, - } { - args := []string{} - if c.Args != "" { - args = strings.Split(c.Args, " ") - } - - expanded, isShell, err := ExpandAlias(args) - - assert.False(t, isShell) - - if err == nil && c.Err != "" { - t.Errorf("expected error %s for %s", c.Err, c.Args) - continue - } - - if err != nil { - eq(t, err.Error(), c.Err) - continue - } - - assert.Equal(t, c.ExpectedArgs, expanded) - } -} - -func TestAliasSet_invalid_command(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - _, err := RunCommand("alias set co 'pe checkout'") - if err == nil { - t.Fatal("expected error") - } - - eq(t, err.Error(), "could not create alias: pe checkout does not correspond to a gh command") -} - -func TestAliasList_empty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - output, err := RunCommand("alias list") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - eq(t, output.String(), "") -} - -func TestAliasList(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author=$1 --label=$2 - clone: repo clone - prs: pr status - cs: config set editor 'quoted path' -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - output, err := RunCommand("alias list") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `clone repo clone -co pr checkout -cs config set editor 'quoted path' -il issue list --author=$1 --label=$2 -prs pr status -` - - eq(t, output.String(), expected) -} - -func TestAliasDelete_nonexistent_command(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - _, err := RunCommand("alias delete cool") - if err == nil { - t.Fatalf("expected error") - } - - eq(t, err.Error(), "no such alias cool") -} - -func TestAliasDelete(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias delete co") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Deleted alias co; was pr checkout") - - expected := `aliases: - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - - eq(t, mainBuf.String(), expected) -} - -func TestShellAlias_flag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set --shell igrep 'gh issue list | grep'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") - expected := `aliases: - igrep: '!gh issue list | grep' -` - - eq(t, mainBuf.String(), expected) -} - -func TestShellAlias_bang(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set igrep '!gh issue list | grep'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") - expected := `aliases: - igrep: '!gh issue list | grep' -` - - eq(t, mainBuf.String(), expected) -} diff --git a/command/completion.go b/command/completion.go deleted file mode 100644 index 706ef311a..000000000 --- a/command/completion.go +++ /dev/null @@ -1,65 +0,0 @@ -package command - -import ( - "errors" - "fmt" - "os" - - "github.com/cli/cli/utils" - "github.com/spf13/cobra" -) - -func init() { - RootCmd.AddCommand(completionCmd) - completionCmd.Flags().StringP("shell", "s", "", "Shell type: {bash|zsh|fish|powershell}") -} - -var completionCmd = &cobra.Command{ - Use: "completion", - Short: "Generate shell completion scripts", - Long: `Generate shell completion scripts for GitHub CLI commands. - -The output of this command will be computer code and is meant to be saved to a -file or immediately evaluated by an interactive shell. - -For example, for bash you could add this to your '~/.bash_profile': - - eval "$(gh completion -s bash)" - -When installing GitHub CLI through a package manager, however, it's possible that -no additional shell configuration is necessary to gain completion support. For -Homebrew, see https://docs.brew.sh/Shell-Completion -`, - RunE: func(cmd *cobra.Command, args []string) error { - shellType, err := cmd.Flags().GetString("shell") - if err != nil { - return err - } - - if shellType == "" { - out := cmd.OutOrStdout() - isTTY := false - if outFile, isFile := out.(*os.File); isFile { - isTTY = utils.IsTerminal(outFile) - } - - if isTTY { - return errors.New("error: the value for `--shell` is required\nsee `gh help completion` for more information") - } - shellType = "bash" - } - - switch shellType { - case "bash": - return RootCmd.GenBashCompletion(cmd.OutOrStdout()) - case "zsh": - return RootCmd.GenZshCompletion(cmd.OutOrStdout()) - case "powershell": - return RootCmd.GenPowerShellCompletion(cmd.OutOrStdout()) - case "fish": - return RootCmd.GenFishCompletion(cmd.OutOrStdout(), true) - default: - return fmt.Errorf("unsupported shell type %q", shellType) - } - }, -} diff --git a/command/completion_test.go b/command/completion_test.go deleted file mode 100644 index 029330a16..000000000 --- a/command/completion_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package command - -import ( - "strings" - "testing" -) - -func TestCompletion_bash(t *testing.T) { - output, err := RunCommand(`completion`) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(output.String(), "complete -o default -F __start_gh gh") { - t.Errorf("problem in bash completion:\n%s", output) - } -} - -func TestCompletion_zsh(t *testing.T) { - output, err := RunCommand(`completion -s zsh`) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(output.String(), "#compdef _gh gh") { - t.Errorf("problem in zsh completion:\n%s", output) - } -} - -func TestCompletion_fish(t *testing.T) { - output, err := RunCommand(`completion -s fish`) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(output.String(), "complete -c gh ") { - t.Errorf("problem in fish completion:\n%s", output) - } -} - -func TestCompletion_powerShell(t *testing.T) { - output, err := RunCommand(`completion -s powershell`) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(output.String(), "Register-ArgumentCompleter") { - t.Errorf("problem in powershell completion:\n%s", output) - } -} - -func TestCompletion_unsupported(t *testing.T) { - _, err := RunCommand(`completion -s csh`) - if err == nil || err.Error() != `unsupported shell type "csh"` { - t.Fatal(err) - } -} diff --git a/command/config.go b/command/config.go deleted file mode 100644 index 0a77e99e5..000000000 --- a/command/config.go +++ /dev/null @@ -1,111 +0,0 @@ -package command - -import ( - "fmt" - - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" -) - -func init() { - RootCmd.AddCommand(configCmd) - configCmd.AddCommand(configGetCmd) - configCmd.AddCommand(configSetCmd) - - configGetCmd.Flags().StringP("host", "h", "", "Get per-host setting") - configSetCmd.Flags().StringP("host", "h", "", "Set per-host setting") - - // TODO reveal and add usage once we properly support multiple hosts - _ = configGetCmd.Flags().MarkHidden("host") - // TODO reveal and add usage once we properly support multiple hosts - _ = configSetCmd.Flags().MarkHidden("host") -} - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Manage configuration for gh", - Long: `Display or change configuration settings for gh. - -Current respected settings: -- git_protocol: "https" or "ssh". Default is "https". -- editor: if unset, defaults to environment variables. -`, -} - -var configGetCmd = &cobra.Command{ - Use: "get ", - Short: "Print the value of a given configuration key", - Example: heredoc.Doc(` - $ gh config get git_protocol - https - `), - Args: cobra.ExactArgs(1), - RunE: configGet, -} - -var configSetCmd = &cobra.Command{ - Use: "set ", - Short: "Update configuration with a value for the given key", - Example: heredoc.Doc(` - $ gh config set editor vim - $ gh config set editor "code --wait" - `), - Args: cobra.ExactArgs(2), - RunE: configSet, -} - -func configGet(cmd *cobra.Command, args []string) error { - key := args[0] - hostname, err := cmd.Flags().GetString("host") - if err != nil { - return err - } - - ctx := contextForCommand(cmd) - - cfg, err := ctx.Config() - if err != nil { - return err - } - - val, err := cfg.Get(hostname, key) - if err != nil { - return err - } - - if val != "" { - out := colorableOut(cmd) - fmt.Fprintf(out, "%s\n", val) - } - - return nil -} - -func configSet(cmd *cobra.Command, args []string) error { - key := args[0] - value := args[1] - - hostname, err := cmd.Flags().GetString("host") - if err != nil { - return err - } - - ctx := contextForCommand(cmd) - - cfg, err := ctx.Config() - if err != nil { - return err - } - - err = cfg.Set(hostname, key, value) - if err != nil { - return fmt.Errorf("failed to set %q to %q: %w", key, value, err) - } - - err = cfg.Write() - if err != nil { - return fmt.Errorf("failed to write config to disk: %w", err) - } - - return nil -} diff --git a/command/config_test.go b/command/config_test.go deleted file mode 100644 index 53654f54f..000000000 --- a/command/config_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package command - -import ( - "bytes" - "testing" - - "github.com/cli/cli/internal/config" -) - -func TestConfigGet(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -editor: ed -` - initBlankContext(cfg, "OWNER/REPO", "master") - - output, err := RunCommand("config get editor") - if err != nil { - t.Fatalf("error running command `config get editor`: %v", err) - } - - eq(t, output.String(), "ed\n") -} - -func TestConfigGet_default(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - output, err := RunCommand("config get git_protocol") - if err != nil { - t.Fatalf("error running command `config get git_protocol`: %v", err) - } - - eq(t, output.String(), "https\n") -} - -func TestConfigGet_not_found(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - - output, err := RunCommand("config get missing") - if err != nil { - t.Fatalf("error running command `config get missing`: %v", err) - } - - eq(t, output.String(), "") -} - -func TestConfigSet(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("config set editor ed") - if err != nil { - t.Fatalf("error running command `config set editor ed`: %v", err) - } - - if len(output.String()) > 0 { - t.Errorf("expected output to be blank: %q", output.String()) - } - - expectedMain := "editor: ed\n" - expectedHosts := `github.com: - user: OWNER - oauth_token: "1234567890" -` - - if mainBuf.String() != expectedMain { - t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String()) - } - if hostsBuf.String() != expectedHosts { - t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String()) - } -} - -func TestConfigSet_update(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -editor: ed -` - - initBlankContext(cfg, "OWNER/REPO", "master") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("config set editor vim") - if err != nil { - t.Fatalf("error running command `config get editor`: %v", err) - } - - if len(output.String()) > 0 { - t.Errorf("expected output to be blank: %q", output.String()) - } - - expectedMain := "editor: vim\n" - expectedHosts := `github.com: - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -` - - if mainBuf.String() != expectedMain { - t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String()) - } - if hostsBuf.String() != expectedHosts { - t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String()) - } -} - -func TestConfigGetHost(t *testing.T) { - cfg := `--- -hosts: - github.com: - git_protocol: ssh - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -editor: ed -git_protocol: https -` - initBlankContext(cfg, "OWNER/REPO", "master") - - output, err := RunCommand("config get -hgithub.com git_protocol") - if err != nil { - t.Fatalf("error running command `config get editor`: %v", err) - } - - eq(t, output.String(), "ssh\n") -} - -func TestConfigGetHost_unset(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN - -editor: ed -git_protocol: ssh -` - initBlankContext(cfg, "OWNER/REPO", "master") - - output, err := RunCommand("config get -hgithub.com git_protocol") - if err != nil { - t.Fatalf("error running command `config get -hgithub.com git_protocol`: %v", err) - } - - eq(t, output.String(), "ssh\n") -} - -func TestConfigSetHost(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("config set -hgithub.com git_protocol ssh") - if err != nil { - t.Fatalf("error running command `config set editor ed`: %v", err) - } - - if len(output.String()) > 0 { - t.Errorf("expected output to be blank: %q", output.String()) - } - - expectedMain := "" - expectedHosts := `github.com: - user: OWNER - oauth_token: "1234567890" - git_protocol: ssh -` - - if mainBuf.String() != expectedMain { - t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String()) - } - if hostsBuf.String() != expectedHosts { - t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String()) - } -} - -func TestConfigSetHost_update(t *testing.T) { - cfg := `--- -hosts: - github.com: - git_protocol: ssh - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -` - - initBlankContext(cfg, "OWNER/REPO", "master") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("config set -hgithub.com git_protocol https") - if err != nil { - t.Fatalf("error running command `config get editor`: %v", err) - } - - if len(output.String()) > 0 { - t.Errorf("expected output to be blank: %q", output.String()) - } - - expectedMain := "" - expectedHosts := `github.com: - git_protocol: https - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -` - - if mainBuf.String() != expectedMain { - t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String()) - } - if hostsBuf.String() != expectedHosts { - t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String()) - } -} diff --git a/command/issue.go b/command/issue.go deleted file mode 100644 index f5a574e5d..000000000 --- a/command/issue.go +++ /dev/null @@ -1,838 +0,0 @@ -package command - -import ( - "fmt" - "io" - "net/url" - "strconv" - "strings" - "time" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/githubtemplate" - "github.com/cli/cli/utils" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -func init() { - issueCmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") - - RootCmd.AddCommand(issueCmd) - issueCmd.AddCommand(issueStatusCmd) - - issueCmd.AddCommand(issueCreateCmd) - issueCreateCmd.Flags().StringP("title", "t", "", - "Supply a title. Will prompt for one otherwise.") - issueCreateCmd.Flags().StringP("body", "b", "", - "Supply a body. Will prompt for one otherwise.") - issueCreateCmd.Flags().BoolP("web", "w", false, "Open the browser to create an issue") - issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`") - issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`") - issueCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the issue to projects by `name`") - issueCreateCmd.Flags().StringP("milestone", "m", "", "Add the issue to a milestone by `name`") - - issueCmd.AddCommand(issueListCmd) - issueListCmd.Flags().BoolP("web", "w", false, "Open the browser to list the issue(s)") - issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") - issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by labels") - issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|all}") - issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of issues to fetch") - issueListCmd.Flags().StringP("author", "A", "", "Filter by author") - issueListCmd.Flags().String("mention", "", "Filter by mention") - issueListCmd.Flags().StringP("milestone", "m", "", "Filter by milestone `name`") - - issueCmd.AddCommand(issueViewCmd) - issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser") - - issueCmd.AddCommand(issueCloseCmd) - issueCmd.AddCommand(issueReopenCmd) -} - -var issueCmd = &cobra.Command{ - Use: "issue ", - Short: "Create and view issues", - Long: `Work with GitHub issues`, - Example: heredoc.Doc(` - $ gh issue list - $ gh issue create --label bug - $ gh issue view --web - `), - Annotations: map[string]string{ - "IsCore": "true", - "help:arguments": `An issue can be supplied as argument in any of the following formats: -- by number, e.g. "123"; or -- by URL, e.g. "https://github.com/OWNER/REPO/issues/123".`}, -} -var issueCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a new issue", - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueCreate, - Example: heredoc.Doc(` - $ gh issue create --title "I found a bug" --body "Nothing works" - $ gh issue create --label "bug,help wanted" - $ gh issue create --label bug --label "help wanted" - $ gh issue create --assignee monalisa,hubot - $ gh issue create --project "Roadmap" - `), -} -var issueListCmd = &cobra.Command{ - Use: "list", - Short: "List and filter issues in this repository", - Example: heredoc.Doc(` - $ gh issue list -l "help wanted" - $ gh issue list -A monalisa - $ gh issue list --web - `), - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueList, -} -var issueStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of relevant issues", - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueStatus, -} -var issueViewCmd = &cobra.Command{ - Use: "view { | }", - Short: "View an issue", - Args: cobra.ExactArgs(1), - Long: `Display the title, body, and other information about an issue. - -With '--web', open the issue in a web browser instead.`, - RunE: issueView, -} -var issueCloseCmd = &cobra.Command{ - Use: "close { | }", - Short: "Close issue", - Args: cobra.ExactArgs(1), - RunE: issueClose, -} -var issueReopenCmd = &cobra.Command{ - Use: "reopen { | }", - Short: "Reopen issue", - Args: cobra.ExactArgs(1), - RunE: issueReopen, -} - -type filterOptions struct { - entity string - state string - assignee string - labels []string - author string - baseBranch string - mention string - milestone string -} - -func listURLWithQuery(listURL string, options filterOptions) (string, error) { - u, err := url.Parse(listURL) - if err != nil { - return "", err - } - query := fmt.Sprintf("is:%s ", options.entity) - if options.state != "all" { - query += fmt.Sprintf("is:%s ", options.state) - } - if options.assignee != "" { - query += fmt.Sprintf("assignee:%s ", options.assignee) - } - for _, label := range options.labels { - query += fmt.Sprintf("label:%s ", quoteValueForQuery(label)) - } - if options.author != "" { - query += fmt.Sprintf("author:%s ", options.author) - } - if options.baseBranch != "" { - query += fmt.Sprintf("base:%s ", options.baseBranch) - } - if options.mention != "" { - query += fmt.Sprintf("mentions:%s ", options.mention) - } - if options.milestone != "" { - query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.milestone)) - } - q := u.Query() - q.Set("q", strings.TrimSuffix(query, " ")) - u.RawQuery = q.Encode() - return u.String(), nil -} - -func quoteValueForQuery(v string) string { - if strings.ContainsAny(v, " \"\t\r\n") { - return fmt.Sprintf("%q", v) - } - return v -} - -func issueList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - state, err := cmd.Flags().GetString("state") - if err != nil { - return err - } - - labels, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return err - } - - assignee, err := cmd.Flags().GetString("assignee") - if err != nil { - return err - } - - limit, err := cmd.Flags().GetInt("limit") - if err != nil { - return err - } - if limit <= 0 { - return fmt.Errorf("invalid limit: %v", limit) - } - - author, err := cmd.Flags().GetString("author") - if err != nil { - return err - } - - mention, err := cmd.Flags().GetString("mention") - if err != nil { - return err - } - - milestone, err := cmd.Flags().GetString("milestone") - if err != nil { - return err - } - - if web { - issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := listURLWithQuery(issueListURL, filterOptions{ - entity: "issue", - state: state, - assignee: assignee, - labels: labels, - author: author, - mention: mention, - milestone: milestone, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } - - listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit, author, mention, milestone) - if err != nil { - return err - } - - hasFilters := false - cmd.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "state", "label", "assignee", "author", "mention", "milestone": - hasFilters = true - } - }) - - title := listHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title) - } - - out := cmd.OutOrStdout() - - printIssues(out, "", len(listResult.Issues), listResult.Issues) - - return nil -} - -func issueStatus(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost()) - if err != nil { - return err - } - - issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) - if err != nil { - return err - } - - out := colorableOut(cmd) - - fmt.Fprintln(out, "") - fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo)) - fmt.Fprintln(out, "") - - printHeader(out, "Issues assigned to you") - if issuePayload.Assigned.TotalCount > 0 { - printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues) - } else { - message := " There are no issues assigned to you" - printMessage(out, message) - } - fmt.Fprintln(out) - - printHeader(out, "Issues mentioning you") - if issuePayload.Mentioned.TotalCount > 0 { - printIssues(out, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues) - } else { - printMessage(out, " There are no issues mentioning you") - } - fmt.Fprintln(out) - - printHeader(out, "Issues opened by you") - if issuePayload.Authored.TotalCount > 0 { - printIssues(out, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues) - } else { - printMessage(out, " There are no issues opened by you") - } - fmt.Fprintln(out) - - return nil -} - -func issueView(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, _, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - openURL := issue.URL - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - if web { - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL) - return utils.OpenInBrowser(openURL) - } - if connectedToTerminal(cmd) { - return printHumanIssuePreview(colorableOut(cmd), issue) - } - - return printRawIssuePreview(cmd.OutOrStdout(), issue) -} - -func issueStateTitleWithColor(state string) string { - colorFunc := colorFuncForState(state) - return colorFunc(strings.Title(strings.ToLower(state))) -} - -func listHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string { - if totalMatchCount == 0 { - if hasFilters { - return fmt.Sprintf("No %ss match your search in %s", itemName, repoName) - } - return fmt.Sprintf("There are no open %ss in %s", itemName, repoName) - } - - if hasFilters { - matchVerb := "match" - if totalMatchCount == 1 { - matchVerb = "matches" - } - return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb) - } - - return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, fmt.Sprintf("open %s", itemName)), repoName) -} - -func printRawIssuePreview(out io.Writer, issue *api.Issue) error { - assignees := issueAssigneeList(*issue) - labels := issueLabelList(*issue) - projects := issueProjectList(*issue) - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(out, "title:\t%s\n", issue.Title) - fmt.Fprintf(out, "state:\t%s\n", issue.State) - fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title) - - fmt.Fprintln(out, "--") - fmt.Fprintln(out, issue.Body) - return nil -} - -func printHumanIssuePreview(out io.Writer, issue *api.Issue) error { - now := time.Now() - ago := now.Sub(issue.CreatedAt) - - // Header (Title and State) - fmt.Fprintln(out, utils.Bold(issue.Title)) - fmt.Fprint(out, issueStateTitleWithColor(issue.State)) - fmt.Fprintln(out, utils.Gray(fmt.Sprintf( - " • %s opened %s • %s", - issue.Author.Login, - utils.FuzzyAgo(ago), - utils.Pluralize(issue.Comments.TotalCount, "comment"), - ))) - - // Metadata - fmt.Fprintln(out) - if assignees := issueAssigneeList(*issue); assignees != "" { - fmt.Fprint(out, utils.Bold("Assignees: ")) - fmt.Fprintln(out, assignees) - } - if labels := issueLabelList(*issue); labels != "" { - fmt.Fprint(out, utils.Bold("Labels: ")) - fmt.Fprintln(out, labels) - } - if projects := issueProjectList(*issue); projects != "" { - fmt.Fprint(out, utils.Bold("Projects: ")) - fmt.Fprintln(out, projects) - } - if issue.Milestone.Title != "" { - fmt.Fprint(out, utils.Bold("Milestone: ")) - fmt.Fprintln(out, issue.Milestone.Title) - } - - // Body - if issue.Body != "" { - fmt.Fprintln(out) - md, err := utils.RenderMarkdown(issue.Body) - if err != nil { - return err - } - fmt.Fprintln(out, md) - } - fmt.Fprintln(out) - - // Footer - fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) - return nil -} - -func issueCreate(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - // NB no auto forking like over in pr create - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - baseOverride, err := cmd.Flags().GetString("repo") - if err != nil { - return err - } - - var nonLegacyTemplateFiles []string - if baseOverride == "" { - if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests - nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE") - } - } - - title, err := cmd.Flags().GetString("title") - if err != nil { - return fmt.Errorf("could not parse title: %w", err) - } - body, err := cmd.Flags().GetString("body") - if err != nil { - return fmt.Errorf("could not parse body: %w", err) - } - - assignees, err := cmd.Flags().GetStringSlice("assignee") - if err != nil { - return fmt.Errorf("could not parse assignees: %w", err) - } - labelNames, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return fmt.Errorf("could not parse labels: %w", err) - } - projectNames, err := cmd.Flags().GetStringSlice("project") - if err != nil { - return fmt.Errorf("could not parse projects: %w", err) - } - var milestoneTitles []string - if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil { - return fmt.Errorf("could not parse milestone: %w", err) - } else if milestoneTitle != "" { - milestoneTitles = append(milestoneTitles, milestoneTitle) - } - - if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { - openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - if title != "" || body != "" { - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone) - if err != nil { - return err - } - } else if len(nonLegacyTemplateFiles) > 1 { - openURL += "/choose" - } - if connectedToTerminal(cmd) { - cmd.Printf("Opening %s in your browser.\n", utils.DisplayURL(openURL)) - } - return utils.OpenInBrowser(openURL) - } - - fmt.Fprintf(colorableErr(cmd), "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) - - repo, err := api.GitHubRepo(apiClient, baseRepo) - if err != nil { - return err - } - if !repo.HasIssuesEnabled { - return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) - } - - action := SubmitAction - tb := issueMetadataState{ - Type: issueMetadata, - Assignees: assignees, - Labels: labelNames, - Projects: projectNames, - Milestones: milestoneTitles, - } - - interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) - - if interactive && !connectedToTerminal(cmd) { - return fmt.Errorf("must provide --title and --body when not attached to a terminal") - } - - if interactive { - var legacyTemplateFile *string - if baseOverride == "" { - if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests - legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE") - } - } - err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) - if err != nil { - return fmt.Errorf("could not collect title and/or body: %w", err) - } - - action = tb.Action - - if tb.Action == CancelAction { - fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") - - return nil - } - - if title == "" { - title = tb.Title - } - if body == "" { - body = tb.Body - } - } else { - if title == "" { - return fmt.Errorf("title can't be blank") - } - } - - if action == PreviewAction { - openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone) - if err != nil { - return err - } - // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } else if action == SubmitAction { - params := map[string]interface{}{ - "title": title, - "body": body, - } - - err = addMetadataToIssueParams(apiClient, baseRepo, params, &tb) - if err != nil { - return err - } - - newIssue, err := api.IssueCreate(apiClient, repo, params) - if err != nil { - return err - } - - fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL) - } else { - panic("Unreachable state") - } - - return nil -} - -func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error { - if !tb.HasMetadata() { - return nil - } - - if tb.MetadataResult == nil { - resolveInput := api.RepoResolveInput{ - Reviewers: tb.Reviewers, - Assignees: tb.Assignees, - Labels: tb.Labels, - Projects: tb.Projects, - Milestones: tb.Milestones, - } - - var err error - tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) - if err != nil { - return err - } - } - - assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) - if err != nil { - return fmt.Errorf("could not assign user: %w", err) - } - params["assigneeIds"] = assigneeIDs - - labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels) - if err != nil { - return fmt.Errorf("could not add label: %w", err) - } - params["labelIds"] = labelIDs - - projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) - if err != nil { - return fmt.Errorf("could not add to project: %w", err) - } - params["projectIds"] = projectIDs - - if len(tb.Milestones) > 0 { - milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0]) - if err != nil { - return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err) - } - params["milestoneId"] = milestoneID - } - - if len(tb.Reviewers) == 0 { - return nil - } - - var userReviewers []string - var teamReviewers []string - for _, r := range tb.Reviewers { - if strings.ContainsRune(r, '/') { - teamReviewers = append(teamReviewers, r) - } else { - userReviewers = append(userReviewers, r) - } - } - - userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) - } - params["userReviewerIds"] = userReviewerIDs - - teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) - } - params["teamReviewerIds"] = teamReviewerIDs - - return nil -} - -func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) { - table := utils.NewTablePrinter(w) - for _, issue := range issues { - issueNum := strconv.Itoa(issue.Number) - if table.IsTTY() { - issueNum = "#" + issueNum - } - issueNum = prefix + issueNum - labels := issueLabelList(issue) - if labels != "" && table.IsTTY() { - labels = fmt.Sprintf("(%s)", labels) - } - now := time.Now() - ago := now.Sub(issue.UpdatedAt) - table.AddField(issueNum, nil, colorFuncForState(issue.State)) - if !table.IsTTY() { - table.AddField(issue.State, nil, nil) - } - table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil) - table.AddField(labels, nil, utils.Gray) - if table.IsTTY() { - table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray) - } else { - table.AddField(issue.UpdatedAt.String(), nil, nil) - } - table.EndRow() - } - _ = table.Render() - remaining := totalCount - len(issues) - if remaining > 0 { - fmt.Fprintf(w, utils.Gray("%sAnd %d more\n"), prefix, remaining) - } -} - -func issueAssigneeList(issue api.Issue) string { - if len(issue.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) - for _, assignee := range issue.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { - list += ", …" - } - return list -} - -func issueLabelList(issue api.Issue) string { - if len(issue.Labels.Nodes) == 0 { - return "" - } - - labelNames := make([]string, 0, len(issue.Labels.Nodes)) - for _, label := range issue.Labels.Nodes { - labelNames = append(labelNames, label.Name) - } - - list := strings.Join(labelNames, ", ") - if issue.Labels.TotalCount > len(issue.Labels.Nodes) { - list += ", …" - } - return list -} - -func issueProjectList(issue api.Issue) string { - if len(issue.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) - for _, project := range issue.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func issueClose(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - - if issue.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title) - return nil - } - - err = api.IssueClose(apiClient, baseRepo, *issue) - if err != nil { - return err - } - - fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title) - - return nil -} - -func issueReopen(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - - if !issue.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title) - return nil - } - - err = api.IssueReopen(apiClient, baseRepo, *issue) - if err != nil { - return err - } - - fmt.Fprintf(colorableErr(cmd), "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title) - - return nil -} diff --git a/command/issue_test.go b/command/issue_test.go deleted file mode 100644 index 8cde92d6e..000000000 --- a/command/issue_test.go +++ /dev/null @@ -1,1141 +0,0 @@ -package command - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "os/exec" - "regexp" - "strings" - "testing" - - "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/test" - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/assert" -) - -func TestIssueStatus(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.FileResponse("../test/fixtures/issueStatus.json")) - - output, err := RunCommand("issue status") - if err != nil { - t.Errorf("error running command `issue status`: %v", err) - } - - expectedIssues := []*regexp.Regexp{ - regexp.MustCompile(`(?m)8.*carrots.*about.*ago`), - regexp.MustCompile(`(?m)9.*squash.*about.*ago`), - regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`), - regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`), - } - - for _, r := range expectedIssues { - if !r.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) - return - } - } -} - -func TestIssueStatus_blankSlate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "assigned": { "nodes": [] }, - "mentioned": { "nodes": [] }, - "authored": { "nodes": [] } - } } }`)) - - output, err := RunCommand("issue status") - if err != nil { - t.Errorf("error running command `issue status`: %v", err) - } - - expectedOutput := ` -Relevant issues in OWNER/REPO - -Issues assigned to you - There are no issues assigned to you - -Issues mentioning you - There are no issues mentioning you - -Issues opened by you - There are no issues opened by you - -` - if output.String() != expectedOutput { - t.Errorf("expected %q, got %q", expectedOutput, output) - } -} - -func TestIssueStatus_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } }`)) - - _, err := RunCommand("issue status") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue status`: %v", err) - } -} - -func TestIssueList_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.FileResponse("../test/fixtures/issueList.json")) - - output, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.Stderr(), "") - test.ExpectLines(t, output.String(), - `1[\t]+number won[\t]+label[\t]+\d+`, - `2[\t]+number too[\t]+label[\t]+\d+`, - `4[\t]+number fore[\t]+label[\t]+\d+`) -} - -func TestIssueList_tty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.FileResponse("../test/fixtures/issueList.json")) - - output, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.Stderr(), ` -Showing 3 of 3 open issues in OWNER/REPO - -`) - - test.ExpectLines(t, output.String(), - "number won", - "number too", - "number fore") -} - -func TestIssueList_tty_withFlags(t *testing.T) { - defer stubTerminal(true)() - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.GraphQLQuery(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } }`, func(_ string, params map[string]interface{}) { - assert.Equal(t, "probablyCher", params["assignee"].(string)) - assert.Equal(t, "foo", params["author"].(string)) - assert.Equal(t, "me", params["mention"].(string)) - assert.Equal(t, "1.x", params["milestone"].(string)) - assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{})) - assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{})) - })) - - output, err := RunCommand("issue list -a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), ` -No issues match your search in OWNER/REPO - -`) -} - -func TestIssueList_withInvalidLimitFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - _, err := RunCommand("issue list --limit=0") - - if err == nil || err.Error() != "invalid limit: 0" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestIssueList_nullAssigneeLabels(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } } - `)) - - _, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables map[string]interface{} - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - _, assigneeDeclared := reqBody.Variables["assignee"] - _, labelsDeclared := reqBody.Variables["labels"] - eq(t, assigneeDeclared, false) - eq(t, labelsDeclared, false) -} - -func TestIssueList_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue list") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestIssueList_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue list --web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1") - if err != nil { - t.Errorf("error running command `issue list` with `--web` flag: %v", err) - } - - expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) -} - -func TestIssueView_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w 123") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueView_web_numberArgWithHash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w \"#123\"") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueView_nontty_Preview(t *testing.T) { - defer stubTerminal(false)() - tests := map[string]struct { - ownerRepo string - command string - fixture string - expectedOutputs []string - }{ - "Open issue without metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_preview.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tOPEN`, - `comments:\t9`, - `author:\tmarseilles`, - `assignees:`, - `\*\*bold story\*\*`, - }, - }, - "Open issue with metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithMetadata.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `assignees:\tmarseilles, monaco`, - `author:\tmarseilles`, - `state:\tOPEN`, - `comments:\t9`, - `labels:\tone, two, three, four, five`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `milestone:\tuluru\n`, - `\*\*bold story\*\*`, - }, - }, - "Open issue with empty body": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithEmptyBody.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tOPEN`, - `author:\tmarseilles`, - `labels:\ttarot`, - }, - }, - "Closed issue": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewClosedState.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tCLOSED`, - `\*\*bold story\*\*`, - `author:\tmarseilles`, - `labels:\ttarot`, - `\*\*bold story\*\*`, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.command) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.command, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestIssueView_tty_Preview(t *testing.T) { - defer stubTerminal(true)() - tests := map[string]struct { - ownerRepo string - command string - fixture string - expectedOutputs []string - }{ - "Open issue without metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_preview.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Open issue with metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithMetadata.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `Milestone:.*uluru\n`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Open issue with empty body": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithEmptyBody.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Closed issue": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewClosedState.json", - expectedOutputs: []string{ - `ix of coins`, - `Closed.*marseilles opened about 292 years ago.*9 comments`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.command) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.command, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestIssueView_web_notFound(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "errors": [ - { "message": "Could not resolve to an Issue with the number of 9999." } - ] } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - _, err := RunCommand("issue view -w 9999") - if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." { - t.Errorf("error running command `issue view`: %v", err) - } - - if seenCmd != nil { - t.Fatal("did not expect any command to run") - } -} - -func TestIssueView_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand(`issue view 6666`) - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue view`: %v", err) - } -} - -func TestIssueView_web_urlArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w https://github.com/OWNER/REPO/issues/123") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueCreate_nontty_error(t *testing.T) { - defer stubTerminal(false)() - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - - _, err := RunCommand(`issue create -t hello`) - if err == nil { - t.Fatal("expected error running command `issue create`") - } - - assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error()) - -} - -func TestIssueCreate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `)) - - output, err := RunCommand(`issue create -t hello -b "cash rules everything around me"`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "hello") - eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") - - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") -} - -func TestIssueCreate_metadata(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query RepositoryNetwork\b`), - httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true, - "viewerPermission": "WRITE" - } } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), - httpmock.StringResponse(` - { "data": { - "u000": { "login": "MonaLisa", "id": "MONAID" }, - "repository": { - "l000": { "name": "bug", "id": "BUGID" }, - "l001": { "name": "TODO", "id": "TODOID" } - } - } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryMilestoneList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "milestones": { - "nodes": [ - { "title": "GA", "id": "GAID" }, - { "title": "Big One.oh", "id": "BIGONEID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryProjectList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "projects": { - "nodes": [ - { "name": "Cleanup", "id": "CLEANUPID" }, - { "name": "Roadmap", "id": "ROADMAPID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectList\b`), - httpmock.StringResponse(` - { "data": { "organization": null }, - "errors": [{ - "type": "NOT_FOUND", - "path": [ "organization" ], - "message": "Could not resolve to an Organization with the login of 'OWNER'." - }] - } - `)) - http.Register( - httpmock.GraphQL(`mutation IssueCreate\b`), - httpmock.GraphQLMutation(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "TITLE") - eq(t, inputs["body"], "BODY") - eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - eq(t, inputs["milestoneId"], "BIGONEID") - if v, ok := inputs["userIds"]; ok { - t.Errorf("did not expect userIds: %v", v) - } - if v, ok := inputs["teamIds"]; ok { - t.Errorf("did not expect teamIds: %v", v) - } - })) - - output, err := RunCommand(`issue create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") -} - -func TestIssueCreate_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand(`issue create -t heres -b johnny`) - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue create`: %v", err) - } -} - -func TestIssueCreate_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - defer stubTerminal(true)() - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand(`issue create --web`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/new") - eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") - eq(t, output.Stderr(), "") -} - -func TestIssueCreate_webTitleBody(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - defer stubTerminal(true)() - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand(`issue create -w -t mytitle -b mybody`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") - eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") - eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") -} - -func Test_listHeader(t *testing.T) { - type args struct { - repoName string - itemName string - matchCount int - totalMatchCount int - hasFilters bool - } - tests := []struct { - name string - args args - want string - }{ - { - name: "no results", - args: args{ - repoName: "REPO", - itemName: "table", - matchCount: 0, - totalMatchCount: 0, - hasFilters: false, - }, - want: "There are no open tables in REPO", - }, - { - name: "no matches after filters", - args: args{ - repoName: "REPO", - itemName: "Luftballon", - matchCount: 0, - totalMatchCount: 0, - hasFilters: true, - }, - want: "No Luftballons match your search in REPO", - }, - { - name: "one result", - args: args{ - repoName: "REPO", - itemName: "genie", - matchCount: 1, - totalMatchCount: 23, - hasFilters: false, - }, - want: "Showing 1 of 23 open genies in REPO", - }, - { - name: "one result after filters", - args: args{ - repoName: "REPO", - itemName: "tiny cup", - matchCount: 1, - totalMatchCount: 23, - hasFilters: true, - }, - want: "Showing 1 of 23 tiny cups in REPO that match your search", - }, - { - name: "one result in total", - args: args{ - repoName: "REPO", - itemName: "chip", - matchCount: 1, - totalMatchCount: 1, - hasFilters: false, - }, - want: "Showing 1 of 1 open chip in REPO", - }, - { - name: "one result in total after filters", - args: args{ - repoName: "REPO", - itemName: "spicy noodle", - matchCount: 1, - totalMatchCount: 1, - hasFilters: true, - }, - want: "Showing 1 of 1 spicy noodle in REPO that matches your search", - }, - { - name: "multiple results", - args: args{ - repoName: "REPO", - itemName: "plant", - matchCount: 4, - totalMatchCount: 23, - hasFilters: false, - }, - want: "Showing 4 of 23 open plants in REPO", - }, - { - name: "multiple results after filters", - args: args{ - repoName: "REPO", - itemName: "boomerang", - matchCount: 4, - totalMatchCount: 23, - hasFilters: true, - }, - want: "Showing 4 of 23 boomerangs in REPO that match your search", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := listHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want { - t.Errorf("listHeader() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestIssueStateTitleWithColor(t *testing.T) { - tests := map[string]struct { - state string - want string - }{ - "Open state": {state: "OPEN", want: "Open"}, - "Closed state": {state: "CLOSED", want: "Closed"}, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := issueStateTitleWithColor(tc.state) - diff := cmp.Diff(tc.want, got) - if diff != "" { - t.Fatalf(diff) - } - }) - } -} - -func TestIssueClose(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue close 13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueClose_alreadyClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue close 13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueClose_issuesDisabled(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue close 13") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } -} - -func TestIssueReopen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": true, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue reopen 2") - if err != nil { - t.Fatalf("error running command `issue reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueReopen_alreadyOpen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": false, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue reopen 2") - if err != nil { - t.Fatalf("error running command `issue reopen`: %v", err) - } - - r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueReopen_issuesDisabled(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue reopen 2") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } -} - -func Test_listURLWithQuery(t *testing.T) { - type args struct { - listURL string - options filterOptions - } - tests := []struct { - name string - args args - want string - wantErr bool - }{ - { - name: "blank", - args: args{ - listURL: "https://example.com/path?a=b", - options: filterOptions{ - entity: "issue", - state: "open", - }, - }, - want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen", - wantErr: false, - }, - { - name: "all", - args: args{ - listURL: "https://example.com/path", - options: filterOptions{ - entity: "issue", - state: "open", - assignee: "bo", - author: "ka", - baseBranch: "trunk", - mention: "nu", - }, - }, - want: "https://example.com/path?q=is%3Aissue+is%3Aopen+assignee%3Abo+author%3Aka+base%3Atrunk+mentions%3Anu", - wantErr: false, - }, - { - name: "spaces in values", - args: args{ - listURL: "https://example.com/path", - options: filterOptions{ - entity: "pr", - state: "open", - labels: []string{"docs", "help wanted"}, - milestone: `Codename "What Was Missing"`, - }, - }, - want: "https://example.com/path?q=is%3Apr+is%3Aopen+label%3Adocs+label%3A%22help+wanted%22+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := listURLWithQuery(tt.args.listURL, tt.args.options) - if (err != nil) != tt.wantErr { - t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("listURLWithQuery() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/command/pr.go b/command/pr.go deleted file mode 100644 index 2090b4dc1..000000000 --- a/command/pr.go +++ /dev/null @@ -1,1044 +0,0 @@ -package command - -import ( - "errors" - "fmt" - "io" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/pkg/text" - "github.com/cli/cli/utils" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -func init() { - prCmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") - - RootCmd.AddCommand(prCmd) - prCmd.AddCommand(prCreateCmd) - prCmd.AddCommand(prStatusCmd) - prCmd.AddCommand(prCloseCmd) - prCmd.AddCommand(prReopenCmd) - prCmd.AddCommand(prMergeCmd) - prMergeCmd.Flags().BoolP("delete-branch", "d", true, "Delete the local and remote branch after merge") - prMergeCmd.Flags().BoolP("merge", "m", false, "Merge the commits with the base branch") - prMergeCmd.Flags().BoolP("rebase", "r", false, "Rebase the commits onto the base branch") - prMergeCmd.Flags().BoolP("squash", "s", false, "Squash the commits into one commit and merge it into the base branch") - prCmd.AddCommand(prReadyCmd) - - prCmd.AddCommand(prListCmd) - prListCmd.Flags().BoolP("web", "w", false, "Open the browser to list the pull request(s)") - prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of items to fetch") - prListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|merged|all}") - prListCmd.Flags().StringP("base", "B", "", "Filter by base branch") - prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by labels") - prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") - - prCmd.AddCommand(prViewCmd) - prViewCmd.Flags().BoolP("web", "w", false, "Open a pull request in the browser") -} - -var prCmd = &cobra.Command{ - Use: "pr ", - Short: "Create, view, and checkout pull requests", - Long: `Work with GitHub pull requests`, - Example: heredoc.Doc(` - $ gh pr checkout 353 - $ gh pr create --fill - $ gh pr view --web - `), - Annotations: map[string]string{ - "IsCore": "true", - "help:arguments": `A pull request can be supplied as argument in any of the following formats: -- by number, e.g. "123"; -- by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or -- by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1".`}, -} -var prListCmd = &cobra.Command{ - Use: "list", - Short: "List and filter pull requests in this repository", - Args: cmdutil.NoArgsQuoteReminder, - Example: heredoc.Doc(` - $ gh pr list --limit 999 - $ gh pr list --state closed - $ gh pr list --label "priority 1" --label "bug" - $ gh pr list --web - `), - RunE: prList, -} -var prStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of relevant pull requests", - Args: cmdutil.NoArgsQuoteReminder, - RunE: prStatus, -} -var prViewCmd = &cobra.Command{ - Use: "view [ | | ]", - Short: "View a pull request", - Long: `Display the title, body, and other information about a pull request. - -Without an argument, the pull request that belongs to the current branch -is displayed. - -With '--web', open the pull request in a web browser instead.`, - RunE: prView, -} -var prCloseCmd = &cobra.Command{ - Use: "close { | | }", - Short: "Close a pull request", - Args: cobra.ExactArgs(1), - RunE: prClose, -} -var prReopenCmd = &cobra.Command{ - Use: "reopen { | | }", - Short: "Reopen a pull request", - Args: cobra.ExactArgs(1), - RunE: prReopen, -} -var prMergeCmd = &cobra.Command{ - Use: "merge [ | | ]", - Short: "Merge a pull request", - Long: heredoc.Doc(` - Merge a pull request on GitHub. - - By default, the head branch of the pull request will get deleted on both remote and local repositories. - To retain the branch, use '--delete-branch=false'. - `), - Args: cobra.MaximumNArgs(1), - RunE: prMerge, -} -var prReadyCmd = &cobra.Command{ - Use: "ready [ | | ]", - Short: "Mark a pull request as ready for review", - Args: cobra.MaximumNArgs(1), - RunE: prReady, -} - -func prStatus(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - repoOverride, _ := cmd.Flags().GetString("repo") - currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx, baseRepo) - - if err != nil && repoOverride == "" && !errors.Is(err, git.ErrNotOnAnyBranch) { - return fmt.Errorf("could not query for pull request for current branch: %w", err) - } - - // the `@me` macro is available because the API lookup is ElasticSearch-based - currentUser := "@me" - prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) - if err != nil { - return err - } - - out := colorableOut(cmd) - - fmt.Fprintln(out, "") - fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo)) - fmt.Fprintln(out, "") - - printHeader(out, "Current branch") - currentPR := prPayload.CurrentPR - currentBranch, _ := ctx.Branch() - if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch { - currentPR = nil - } - if currentPR != nil { - printPrs(out, 1, *currentPR) - } else if currentPRHeadRef == "" { - printMessage(out, " There is no current branch") - } else { - printMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]"))) - } - fmt.Fprintln(out) - - printHeader(out, "Created by you") - if prPayload.ViewerCreated.TotalCount > 0 { - printPrs(out, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...) - } else { - printMessage(out, " You have no open pull requests") - } - fmt.Fprintln(out) - - printHeader(out, "Requesting a code review from you") - if prPayload.ReviewRequested.TotalCount > 0 { - printPrs(out, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...) - } else { - printMessage(out, " You have no pull requests to review") - } - fmt.Fprintln(out) - - return nil -} - -func prList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - limit, err := cmd.Flags().GetInt("limit") - if err != nil { - return err - } - if limit <= 0 { - return fmt.Errorf("invalid limit: %v", limit) - } - - state, err := cmd.Flags().GetString("state") - if err != nil { - return err - } - baseBranch, err := cmd.Flags().GetString("base") - if err != nil { - return err - } - labels, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return err - } - assignee, err := cmd.Flags().GetString("assignee") - if err != nil { - return err - } - - if web { - prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") - openURL, err := listURLWithQuery(prListURL, filterOptions{ - entity: "pr", - state: state, - assignee: assignee, - labels: labels, - baseBranch: baseBranch, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } - - var graphqlState []string - switch state { - case "open": - graphqlState = []string{"OPEN"} - case "closed": - graphqlState = []string{"CLOSED", "MERGED"} - case "merged": - graphqlState = []string{"MERGED"} - case "all": - graphqlState = []string{"OPEN", "CLOSED", "MERGED"} - default: - return fmt.Errorf("invalid state: %s", state) - } - - params := map[string]interface{}{ - "state": graphqlState, - } - if len(labels) > 0 { - params["labels"] = labels - } - if baseBranch != "" { - params["baseBranch"] = baseBranch - } - if assignee != "" { - params["assignee"] = assignee - } - - listResult, err := api.PullRequestList(apiClient, baseRepo, params, limit) - if err != nil { - return err - } - - hasFilters := false - cmd.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "state", "label", "base", "assignee": - hasFilters = true - } - }) - - title := listHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title) - } - - table := utils.NewTablePrinter(cmd.OutOrStdout()) - for _, pr := range listResult.PullRequests { - prNum := strconv.Itoa(pr.Number) - if table.IsTTY() { - prNum = "#" + prNum - } - table.AddField(prNum, nil, colorFuncForPR(pr)) - table.AddField(replaceExcessiveWhitespace(pr.Title), nil, nil) - table.AddField(pr.HeadLabel(), nil, utils.Cyan) - if !table.IsTTY() { - table.AddField(prStateWithDraft(&pr), nil, nil) - } - table.EndRow() - } - err = table.Render() - if err != nil { - return err - } - - return nil -} - -func prStateTitleWithColor(pr api.PullRequest) string { - prStateColorFunc := colorFuncForPR(pr) - if pr.State == "OPEN" && pr.IsDraft { - return prStateColorFunc(strings.Title(strings.ToLower("Draft"))) - } - return prStateColorFunc(strings.Title(strings.ToLower(pr.State))) -} - -func colorFuncForPR(pr api.PullRequest) func(string) string { - if pr.State == "OPEN" && pr.IsDraft { - return utils.Gray - } - return colorFuncForState(pr.State) -} - -// colorFuncForState returns a color function for a PR/Issue state -func colorFuncForState(state string) func(string) string { - switch state { - case "OPEN": - return utils.Green - case "CLOSED": - return utils.Red - case "MERGED": - return utils.Magenta - default: - return nil - } -} - -func prView(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - pr, _, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - openURL := pr.URL - - if web { - if connectedToTerminal(cmd) { - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL) - } - return utils.OpenInBrowser(openURL) - } - - if connectedToTerminal(cmd) { - out := colorableOut(cmd) - return printHumanPrPreview(out, pr) - } - - return printRawPrPreview(cmd.OutOrStdout(), pr) -} - -func prClose(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be closed because it was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d (%s) is already closed\n", utils.Yellow("!"), pr.Number, pr.Title) - return nil - } - - err = api.PullRequestClose(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title) - - return nil -} - -func prReopen(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be reopened because it was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } - - if !pr.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d (%s) is already open\n", utils.Yellow("!"), pr.Number, pr.Title) - return nil - } - - err = api.PullRequestReopen(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Reopened pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) - - return nil -} - -func prMerge(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.Mergeable == "CONFLICTING" { - err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.Mergeable == "UNKNOWN" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } - - var mergeMethod api.PullRequestMergeMethod - deleteBranch, err := cmd.Flags().GetBool("delete-branch") - if err != nil { - return err - } - - deleteLocalBranch := !cmd.Flags().Changed("repo") - crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() - - // Ensure only one merge method is specified - enabledFlagCount := 0 - isInteractive := false - if b, _ := cmd.Flags().GetBool("merge"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodMerge - } - if b, _ := cmd.Flags().GetBool("rebase"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodRebase - } - if b, _ := cmd.Flags().GetBool("squash"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodSquash - } - - if enabledFlagCount == 0 { - if !connectedToTerminal(cmd) { - return errors.New("--merge, --rebase, or --squash required when not attached to a tty") - } - isInteractive = true - } else if enabledFlagCount > 1 { - return errors.New("expected exactly one of --merge, --rebase, or --squash to be true") - } - - if isInteractive { - mergeMethod, deleteBranch, err = prInteractiveMerge(deleteLocalBranch, crossRepoPR) - if err != nil { - return nil - } - } - - var action string - if mergeMethod == api.PullRequestMergeMethodRebase { - action = "Rebased and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) - } else if mergeMethod == api.PullRequestMergeMethodSquash { - action = "Squashed and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) - } else if mergeMethod == api.PullRequestMergeMethodMerge { - action = "Merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) - } else { - err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) - return err - } - - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "%s %s pull request #%d (%s)\n", utils.Magenta("✔"), action, pr.Number, pr.Title) - } - - if deleteBranch { - branchSwitchString := "" - - if deleteLocalBranch && !crossRepoPR { - currentBranch, err := ctx.Branch() - if err != nil { - return err - } - - var branchToSwitchTo string - if currentBranch == pr.HeadRefName { - branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - err = git.CheckoutBranch(branchToSwitchTo) - if err != nil { - return err - } - } - - localBranchExists := git.HasLocalBranch(pr.HeadRefName) - if localBranchExists { - err = git.DeleteLocalBranch(pr.HeadRefName) - if err != nil { - err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err) - return err - } - } - - if branchToSwitchTo != "" { - branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo)) - } - } - - if !crossRepoPR { - err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) - var httpErr api.HTTPError - // The ref might have already been deleted by GitHub - if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { - err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err) - return err - } - } - - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString) - } - } - - return nil -} - -func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { - mergeMethodQuestion := &survey.Question{ - Name: "mergeMethod", - Prompt: &survey.Select{ - Message: "What merge method would you like to use?", - Options: []string{"Create a merge commit", "Rebase and merge", "Squash and merge"}, - Default: "Create a merge commit", - }, - } - - qs := []*survey.Question{mergeMethodQuestion} - - if !crossRepoPR { - var message string - if deleteLocalBranch { - message = "Delete the branch locally and on GitHub?" - } else { - message = "Delete the branch on GitHub?" - } - - deleteBranchQuestion := &survey.Question{ - Name: "deleteBranch", - Prompt: &survey.Confirm{ - Message: message, - Default: true, - }, - } - qs = append(qs, deleteBranchQuestion) - } - - answers := struct { - MergeMethod int - DeleteBranch bool - }{} - - err := prompt.SurveyAsk(qs, &answers) - if err != nil { - return 0, false, fmt.Errorf("could not prompt: %w", err) - } - - var mergeMethod api.PullRequestMergeMethod - switch answers.MergeMethod { - case 0: - mergeMethod = api.PullRequestMergeMethodMerge - case 1: - mergeMethod = api.PullRequestMergeMethodRebase - case 2: - mergeMethod = api.PullRequestMergeMethodSquash - } - - deleteBranch := answers.DeleteBranch - return mergeMethod, deleteBranch, nil -} - -func prStateWithDraft(pr *api.PullRequest) string { - if pr.IsDraft && pr.State == "OPEN" { - return "DRAFT" - } - - return pr.State -} - -func printRawPrPreview(out io.Writer, pr *api.PullRequest) error { - reviewers := prReviewerList(*pr) - assignees := prAssigneeList(*pr) - labels := prLabelList(*pr) - projects := prProjectList(*pr) - - fmt.Fprintf(out, "title:\t%s\n", pr.Title) - fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) - fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) - fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title) - - fmt.Fprintln(out, "--") - fmt.Fprintln(out, pr.Body) - - return nil -} - -func printHumanPrPreview(out io.Writer, pr *api.PullRequest) error { - // Header (Title and State) - fmt.Fprintln(out, utils.Bold(pr.Title)) - fmt.Fprintf(out, "%s", prStateTitleWithColor(*pr)) - fmt.Fprintln(out, utils.Gray(fmt.Sprintf( - " • %s wants to merge %s into %s from %s", - pr.Author.Login, - utils.Pluralize(pr.Commits.TotalCount, "commit"), - pr.BaseRefName, - pr.HeadRefName, - ))) - fmt.Fprintln(out) - - // Metadata - if reviewers := prReviewerList(*pr); reviewers != "" { - fmt.Fprint(out, utils.Bold("Reviewers: ")) - fmt.Fprintln(out, reviewers) - } - if assignees := prAssigneeList(*pr); assignees != "" { - fmt.Fprint(out, utils.Bold("Assignees: ")) - fmt.Fprintln(out, assignees) - } - if labels := prLabelList(*pr); labels != "" { - fmt.Fprint(out, utils.Bold("Labels: ")) - fmt.Fprintln(out, labels) - } - if projects := prProjectList(*pr); projects != "" { - fmt.Fprint(out, utils.Bold("Projects: ")) - fmt.Fprintln(out, projects) - } - if pr.Milestone.Title != "" { - fmt.Fprint(out, utils.Bold("Milestone: ")) - fmt.Fprintln(out, pr.Milestone.Title) - } - - // Body - if pr.Body != "" { - fmt.Fprintln(out) - md, err := utils.RenderMarkdown(pr.Body) - if err != nil { - return err - } - fmt.Fprintln(out, md) - } - fmt.Fprintln(out) - - // Footer - fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL) - return nil -} - -func prReady(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.Closed { - err := fmt.Errorf("%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number) - return err - } else if !pr.IsDraft { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already \"ready for review\"\n", utils.Yellow("!"), pr.Number) - return nil - } - - err = api.PullRequestReady(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is marked as \"ready for review\"\n", utils.Green("✔"), pr.Number) - - return nil -} - -// Ref. https://developer.github.com/v4/enum/pullrequestreviewstate/ -const ( - requestedReviewState = "REQUESTED" // This is our own state for review request - approvedReviewState = "APPROVED" - changesRequestedReviewState = "CHANGES_REQUESTED" - commentedReviewState = "COMMENTED" - dismissedReviewState = "DISMISSED" - pendingReviewState = "PENDING" -) - -type reviewerState struct { - Name string - State string -} - -// colorFuncForReviewerState returns a color function for a reviewer state -func colorFuncForReviewerState(state string) func(string) string { - switch state { - case requestedReviewState: - return utils.Yellow - case approvedReviewState: - return utils.Green - case changesRequestedReviewState: - return utils.Red - case commentedReviewState: - return func(str string) string { return str } // Do nothing - default: - return nil - } -} - -// formattedReviewerState formats a reviewerState with state color -func formattedReviewerState(reviewer *reviewerState) string { - state := reviewer.State - if state == dismissedReviewState { - // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes - // sense when displayed in an events timeline but not in the final tally. - state = commentedReviewState - } - stateColorFunc := colorFuncForReviewerState(state) - return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " "))) -} - -// prReviewerList generates a reviewer list with their last state -func prReviewerList(pr api.PullRequest) string { - reviewerStates := parseReviewers(pr) - reviewers := make([]string, 0, len(reviewerStates)) - - sortReviewerStates(reviewerStates) - - for _, reviewer := range reviewerStates { - reviewers = append(reviewers, formattedReviewerState(reviewer)) - } - - reviewerList := strings.Join(reviewers, ", ") - - return reviewerList -} - -// Ref. https://developer.github.com/v4/union/requestedreviewer/ -const teamTypeName = "Team" - -const ghostName = "ghost" - -// parseReviewers parses given Reviews and ReviewRequests -func parseReviewers(pr api.PullRequest) []*reviewerState { - reviewerStates := make(map[string]*reviewerState) - - for _, review := range pr.Reviews.Nodes { - if review.Author.Login != pr.Author.Login { - name := review.Author.Login - if name == "" { - name = ghostName - } - reviewerStates[name] = &reviewerState{ - Name: name, - State: review.State, - } - } - } - - // Overwrite reviewer's state if a review request for the same reviewer exists. - for _, reviewRequest := range pr.ReviewRequests.Nodes { - name := reviewRequest.RequestedReviewer.Login - if reviewRequest.RequestedReviewer.TypeName == teamTypeName { - name = reviewRequest.RequestedReviewer.Name - } - reviewerStates[name] = &reviewerState{ - Name: name, - State: requestedReviewState, - } - } - - // Convert map to slice for ease of sort - result := make([]*reviewerState, 0, len(reviewerStates)) - for _, reviewer := range reviewerStates { - if reviewer.State == pendingReviewState { - continue - } - result = append(result, reviewer) - } - - return result -} - -// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically -func sortReviewerStates(reviewerStates []*reviewerState) { - sort.Slice(reviewerStates, func(i, j int) bool { - if reviewerStates[i].State == requestedReviewState && - reviewerStates[j].State != requestedReviewState { - return false - } - if reviewerStates[j].State == requestedReviewState && - reviewerStates[i].State != requestedReviewState { - return true - } - - return reviewerStates[i].Name < reviewerStates[j].Name - }) -} - -func prAssigneeList(pr api.PullRequest) string { - if len(pr.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) - for _, assignee := range pr.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { - list += ", …" - } - return list -} - -func prLabelList(pr api.PullRequest) string { - if len(pr.Labels.Nodes) == 0 { - return "" - } - - labelNames := make([]string, 0, len(pr.Labels.Nodes)) - for _, label := range pr.Labels.Nodes { - labelNames = append(labelNames, label.Name) - } - - list := strings.Join(labelNames, ", ") - if pr.Labels.TotalCount > len(pr.Labels.Nodes) { - list += ", …" - } - return list -} - -func prProjectList(pr api.PullRequest) string { - if len(pr.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) - for _, project := range pr.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func prSelectorForCurrentBranch(ctx context.Context, baseRepo ghrepo.Interface) (prNumber int, prHeadRef string, err error) { - prHeadRef, err = ctx.Branch() - if err != nil { - return - } - branchConfig := git.ReadBranchConfig(prHeadRef) - - // the branch is configured to merge a special PR head ref - prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) - if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - prNumber, _ = strconv.Atoi(m[1]) - return - } - - var branchOwner string - if branchConfig.RemoteURL != nil { - // the branch merges from a remote specified by URL - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - branchOwner = r.RepoOwner() - } - } else if branchConfig.RemoteName != "" { - // the branch merges from a remote specified by name - rem, _ := ctx.Remotes() - if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { - branchOwner = r.RepoOwner() - } - } - - if branchOwner != "" { - if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { - prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") - } - // prepend `OWNER:` if this branch is pushed to a fork - if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { - prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) - } - } - - return -} - -func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) { - for _, pr := range prs { - prNumber := fmt.Sprintf("#%d", pr.Number) - - prStateColorFunc := utils.Green - if pr.IsDraft { - prStateColorFunc = utils.Gray - } else if pr.State == "MERGED" { - prStateColorFunc = utils.Magenta - } else if pr.State == "CLOSED" { - prStateColorFunc = utils.Red - } - - fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, replaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) - - checks := pr.ChecksStatus() - reviews := pr.ReviewStatus() - - if pr.State == "OPEN" { - reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired - if checks.Total > 0 || reviewStatus { - // show checks & reviews on their own line - fmt.Fprintf(w, "\n ") - } - - if checks.Total > 0 { - var summary string - if checks.Failing > 0 { - if checks.Failing == checks.Total { - summary = utils.Red("× All checks failing") - } else { - summary = utils.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total)) - } - } else if checks.Pending > 0 { - summary = utils.Yellow("- Checks pending") - } else if checks.Passing == checks.Total { - summary = utils.Green("✓ Checks passing") - } - fmt.Fprint(w, summary) - } - - if checks.Total > 0 && reviewStatus { - // add padding between checks & reviews - fmt.Fprint(w, " ") - } - - if reviews.ChangesRequested { - fmt.Fprint(w, utils.Red("+ Changes requested")) - } else if reviews.ReviewRequired { - fmt.Fprint(w, utils.Yellow("- Review required")) - } else if reviews.Approved { - fmt.Fprint(w, utils.Green("✓ Approved")) - } - } else { - fmt.Fprintf(w, " - %s", prStateTitleWithColor(pr)) - } - - fmt.Fprint(w, "\n") - } - remaining := totalCount - len(prs) - if remaining > 0 { - fmt.Fprintf(w, utils.Gray(" And %d more\n"), remaining) - } -} - -func printHeader(w io.Writer, s string) { - fmt.Fprintln(w, utils.Bold(s)) -} - -func printMessage(w io.Writer, s string) { - fmt.Fprintln(w, utils.Gray(s)) -} - -func replaceExcessiveWhitespace(s string) string { - s = strings.TrimSpace(s) - s = regexp.MustCompile(`\r?\n`).ReplaceAllString(s, " ") - s = regexp.MustCompile(`\s{2,}`).ReplaceAllString(s, " ") - return s -} diff --git a/command/pr_lookup.go b/command/pr_lookup.go deleted file mode 100644 index 727259f7b..000000000 --- a/command/pr_lookup.go +++ /dev/null @@ -1,34 +0,0 @@ -package command - -import ( - "fmt" - - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmd/pr/shared" - "github.com/spf13/cobra" -) - -func prFromArgs(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, args []string) (*api.PullRequest, ghrepo.Interface, error) { - var arg string - if len(args) > 0 { - arg = args[0] - } - - return shared.PRFromArgs( - apiClient, - func() (ghrepo.Interface, error) { - repo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return nil, fmt.Errorf("could not determine base repo: %w", err) - } - return repo, nil - }, - func() (string, error) { - return ctx.Branch() - }, - func() (context.Remotes, error) { - return ctx.Remotes() - }, arg) -} diff --git a/command/pr_test.go b/command/pr_test.go deleted file mode 100644 index f11939b1b..000000000 --- a/command/pr_test.go +++ /dev/null @@ -1,1731 +0,0 @@ -package command - -import ( - "bytes" - "os/exec" - "reflect" - "regexp" - "strings" - "testing" - - "github.com/cli/cli/api" - "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/test" - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/assert" -) - -func eq(t *testing.T, got interface{}, expected interface{}) { - t.Helper() - if !reflect.DeepEqual(got, expected) { - t.Errorf("expected: %v, got: %v", expected, got) - } -} - -func TestPRStatus(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatus.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedPrs := []*regexp.Regexp{ - regexp.MustCompile(`#8.*\[strawberries\]`), - regexp.MustCompile(`#9.*\[apples\]`), - regexp.MustCompile(`#10.*\[blueberries\]`), - regexp.MustCompile(`#11.*\[figs\]`), - } - - for _, r := range expectedPrs { - if !r.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/", r) - } - } -} - -func TestPRStatus_fork(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubForkedRepoResponse("OWNER/REPO", "PARENT/REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusFork.json")) - - defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{Out: []byte(`branch.blueberries.remote origin -branch.blueberries.merge refs/heads/blueberries`)} - default: - panic("not implemented") - } - })() - - output, err := RunCommand("pr status") - if err != nil { - t.Fatalf("error running command `pr status`: %v", err) - } - - branchRE := regexp.MustCompile(`#10.*\[OWNER:blueberries\]`) - if !branchRE.MatchString(output.String()) { - t.Errorf("did not match current branch:\n%v", output.String()) - } -} - -func TestPRStatus_reviewsAndChecks(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusChecks.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := []string{ - "✓ Checks passing + Changes requested", - "- Checks pending ✓ Approved", - "× 1/3 checks failing - Review required", - } - - for _, line := range expected { - if !strings.Contains(output.String(), line) { - t.Errorf("output did not contain %q: %q", line, output.String()) - } - } -} - -func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } - - unexpectedLines := []*regexp.Regexp{ - regexp.MustCompile(`#9 Blueberries are a good fruit \[blueberries\] - Merged`), - regexp.MustCompile(`#8 Blueberries are probably a good fruit \[blueberries\] - Closed`), - } - for _, r := range unexpectedLines { - if r.MatchString(output.String()) { - t.Errorf("output unexpectedly match regexp /%s/\n> output\n%s\n", r, output) - return - } - } -} - -func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_defaultBranch_repoFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) - - output, err := RunCommand("pr status -R OWNER/REPO") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\]`) - if expectedLine.MatchString(output.String()) { - t.Errorf("output not expected to match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Closed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosed.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Closed`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Merged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchMerged.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Merged`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_blankSlate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := ` -Relevant pull requests in OWNER/REPO - -Current branch - There is no pull request associated with [blueberries] - -Created by you - You have no open pull requests - -Requesting a code review from you - You have no pull requests to review - -` - if output.String() != expected { - t.Errorf("expected %q, got %q", expected, output.String()) - } -} - -func TestPRStatus_detachedHead(t *testing.T) { - initBlankContext("", "OWNER/REPO", "") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := ` -Relevant pull requests in OWNER/REPO - -Current branch - There is no current branch - -Created by you - You have no open pull requests - -Requesting a code review from you - You have no pull requests to review - -` - if output.String() != expected { - t.Errorf("expected %q, got %q", expected, output.String()) - } -} - -func TestPRList(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("../test/fixtures/prList.json")) - - output, err := RunCommand("pr list") - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, ` -Showing 3 of 3 open pull requests in OWNER/REPO - -`, output.Stderr()) - - lines := strings.Split(output.String(), "\n") - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } -} - -func TestPRList_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("../test/fixtures/prList.json")) - - output, err := RunCommand("pr list") - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "", output.Stderr()) - - assert.Equal(t, `32 New feature feature DRAFT -29 Fixed bad bug hubot:bug-fix OPEN -28 Improve documentation docs MERGED -`, output.String()) -} - -func TestPRList_filtering(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) - assert.Equal(t, []interface{}{"one", "two", "three"}, params["labels"].([]interface{})) - })) - - output, err := RunCommand(`pr list -s all -l one,two -l three`) - if err != nil { - t.Fatal(err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), ` -No pull requests match your search in OWNER/REPO - -`) -} - -func TestPRList_filteringRemoveDuplicate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.FileResponse("../test/fixtures/prListWithDuplicates.json")) - - output, err := RunCommand("pr list -l one,two") - if err != nil { - t.Fatal(err) - } - - lines := strings.Split(output.String(), "\n") - - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } -} - -func TestPRList_filteringClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) - })) - - _, err := RunCommand(`pr list -s closed`) - if err != nil { - t.Fatal(err) - } -} - -func TestPRList_filteringAssignee(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, `repo:OWNER/REPO assignee:hubot is:pr sort:created-desc is:merged label:"needs tests" base:"develop"`, params["q"].(string)) - })) - - _, err := RunCommand(`pr list -s merged -l "needs tests" -a hubot -B develop`) - if err != nil { - t.Fatal(err) - } -} - -func TestPRList_filteringAssigneeLabels(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - initFakeHTTP() - - _, err := RunCommand(`pr list -l one,two -a hubot`) - if err == nil && err.Error() != "multiple labels with --assignee are not supported" { - t.Fatal(err) - } -} - -func TestPRList_withInvalidLimitFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - initFakeHTTP() - - _, err := RunCommand(`pr list --limit=0`) - if err == nil && err.Error() != "invalid limit: 0" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestPRList_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr list --web -a peter -l bug -l docs -L 10 -s merged -B trunk") - if err != nil { - t.Errorf("error running command `pr list` with `--web` flag: %v", err) - } - - expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk" - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) -} - -func TestPRView_Preview_nontty(t *testing.T) { - defer stubTerminal(false)() - tests := map[string]struct { - ownerRepo string - args string - fixture string - expectedOutputs []string - }{ - "Open PR without metadata": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreview.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tOPEN\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `blueberries taste good`, - }, - }, - "Open PR with metadata by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithMetadataByNumber.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`, - `assignees:\tmarseilles, monaco\n`, - `labels:\tone, two, three, four, five\n`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `milestone:\tuluru\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR with reviewers by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithReviewersByNumber.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tOPEN\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR with metadata by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewWithMetadataByBranch.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\tmarseilles, monaco\n`, - `labels:\tone, two, three, four, five\n`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, - `milestone:\tuluru\n`, - `blueberries taste good`, - }, - }, - "Open PR for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR wth empty body for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView_EmptyBody.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - }, - }, - "Closed PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewClosedState.json", - expectedOutputs: []string{ - `state:\tCLOSED\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Merged PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewMergedState.json", - expectedOutputs: []string{ - `state:\tMERGED\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Draft PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewDraftState.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tDRAFT\n`, - `author:\tnobody\n`, - `labels:`, - `assignees:`, - `projects:`, - `milestone:`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Draft PR by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewDraftStatebyBranch.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit\n`, - `state:\tDRAFT\n`, - `author:\tnobody\n`, - `labels:`, - `assignees:`, - `projects:`, - `milestone:`, - `\*\*blueberries taste good\*\*`, - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.args) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.args, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestPRView_Preview(t *testing.T) { - defer stubTerminal(true)() - tests := map[string]struct { - ownerRepo string - args string - fixture string - expectedOutputs []string - }{ - "Open PR without metadata": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreview.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Open.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Open PR with metadata by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithMetadataByNumber.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Open.*nobody wants to merge 12 commits into master from blueberries`, - `Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `Milestone:.*uluru\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, - }, - }, - "Open PR with reviewers by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithReviewersByNumber.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, - }, - }, - "Open PR with metadata by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewWithMetadataByBranch.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, - `Milestone:.*uluru\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`, - }, - }, - "Open PR for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Open PR wth empty body for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView_EmptyBody.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Closed PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewClosedState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Closed.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Merged PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewMergedState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Merged.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Draft PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewDraftState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Draft.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Draft PR by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewDraftStatebyBranch.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Draft.*nobody wants to merge 8 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.args) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.args, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestPRView_web_currentBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("../test/fixtures/prView.json")) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{} - default: - seenCmd = cmd - return &test.OutputStub{} - } - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - if url != "https://github.com/OWNER/REPO/pull/10" { - t.Errorf("got: %q", url) - } -} - -func TestPRView_web_noResultsForBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("../test/fixtures/prView_NoActiveBranch.json")) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{} - default: - seenCmd = cmd - return &test.OutputStub{} - } - }) - defer restoreCmd() - - _, err := RunCommand("pr view -w") - if err == nil || err.Error() != `no open pull requests found for branch "blueberries"` { - t.Errorf("error running command `pr view`: %v", err) - } - - if seenCmd != nil { - t.Fatalf("unexpected command: %v", seenCmd.Args) - } -} - -func TestPRView_web_numberArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w 23") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_numberArgWithHash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w \"#23\"") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_urlArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w https://github.com/OWNER/REPO/pull/23/files") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_branchArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": false, - "url": "https://github.com/OWNER/REPO/pull/23" } - ] } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w blueberries") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_branchWithOwnerArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": true, - "headRepositoryOwner": { "login": "hubot" }, - "url": "https://github.com/hubot/REPO/pull/23" } - ] } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w hubot:blueberries") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/hubot/REPO/pull/23") -} - -func TestReplaceExcessiveWhitespace(t *testing.T) { - eq(t, replaceExcessiveWhitespace("hello\ngoodbye"), "hello goodbye") - eq(t, replaceExcessiveWhitespace(" hello goodbye "), "hello goodbye") - eq(t, replaceExcessiveWhitespace("hello goodbye"), "hello goodbye") - eq(t, replaceExcessiveWhitespace(" hello \n goodbye "), "hello goodbye") -} - -func TestPrStateTitleWithColor(t *testing.T) { - tests := map[string]struct { - pr api.PullRequest - want string - }{ - "Format OPEN state": {pr: api.PullRequest{State: "OPEN", IsDraft: false}, want: "Open"}, - "Format OPEN state for Draft PR": {pr: api.PullRequest{State: "OPEN", IsDraft: true}, want: "Draft"}, - "Format CLOSED state": {pr: api.PullRequest{State: "CLOSED", IsDraft: false}, want: "Closed"}, - "Format MERGED state": {pr: api.PullRequest{State: "MERGED", IsDraft: false}, want: "Merged"}, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := prStateTitleWithColor(tc.pr) - diff := cmp.Diff(tc.want, got) - if diff != "" { - t.Fatalf(diff) - } - }) - } -} - -func TestPrClose(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR" } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr close 96") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrClose_alreadyClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr close 101") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen_alreadyOpen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen_alreadyMerged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err == nil { - t.Fatalf("expected an error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 1, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") - - output, err := RunCommand("pr merge 1 --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(`{ - "data": { - "repository": { - "defaultBranchRef": {"name": "master"} - } - } - }`)) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") - - output, err := RunCommand("pr merge 1 --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - assert.Equal(t, "", output.String()) - assert.Equal(t, "", output.Stderr()) -} - -func TestPrMerge_nontty_insufficient_flags(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`)) - - output, err := RunCommand("pr merge 1") - if err == nil { - t.Fatal("expected error") - } - - assert.Equal(t, "--merge, --rebase, or --squash required when not attached to a tty", err.Error()) - assert.Equal(t, "", output.String()) -} - -func TestPrMerge_withRepoFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.GraphQLQuery(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`, func(_ string, params map[string]interface{}) { - assert.Equal(t, "stinky", params["owner"].(string)) - assert.Equal(t, "boi", params["repo"].(string)) - })) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - eq(t, len(cs.Calls), 0) - - output, err := RunCommand("pr merge 1 --merge -R stinky/boi") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_deleteBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git checkout master - cs.Stub("") // git rev-parse --verify blueberries` - cs.Stub("") // git branch -d - cs.Stub("") // git push origin --delete blueberries - - output, err := RunCommand(`pr merge --merge --delete-branch`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) -} - -func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "another-branch") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - // We don't expect the default branch to be checked out, just that blueberries is deleted - cs.Stub("") // git rev-parse --verify blueberries - cs.Stub("") // git branch -d blueberries - cs.Stub("") // git push origin --delete blueberries - - output, err := RunCommand(`pr merge --merge --delete-branch blueberries`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) -} - -func TestPrMerge_noPrNumberGiven(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #10 \(Blueberries are a good fruit\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_rebase(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 2, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "REBASE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge 2 --rebase") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Rebased and merged pull request #2 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_squash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 3, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge 3 --squash") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) -} - -func TestPrMerge_alreadyMerged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} - } } }`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge 4") - if err == nil { - t.Fatalf("expected an error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRMerge_interactive(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "number": 3 - }] } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git push origin --delete blueberries - cs.Stub("") // git branch -d - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "mergeMethod", - Value: 0, - }, - { - Name: "deleteBranch", - Value: true, - }, - }) - - output, err := RunCommand(`pr merge`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) -} - -func TestPrMerge_multipleMergeMethods(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - - _, err := RunCommand("pr merge 1 --merge --squash") - if err == nil { - t.Fatal("expected error running `pr merge` with multiple merge methods") - } -} - -func TestPrMerge_multipleMergeMethods_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - - _, err := RunCommand("pr merge 1 --merge --squash") - if err == nil { - t.Fatal("expected error running `pr merge` with multiple merge methods") - } -} - -func TestPRReady(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 444, "closed": false, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr ready 444") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReady_alreadyReady(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 445, "closed": false, "isDraft": false} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr ready 445") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #445 is already "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReady_closed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 446, "closed": true, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - _, err := RunCommand("pr ready 446") - if err == nil { - t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err) - } - - r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, err.Error()) - } -} diff --git a/command/root.go b/command/root.go index 452db221b..5e5260793 100644 --- a/command/root.go +++ b/command/root.go @@ -1,44 +1,15 @@ package command import ( - "errors" "fmt" - "io" - "net/http" "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" "runtime/debug" "strings" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/git" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/internal/run" - apiCmd "github.com/cli/cli/pkg/cmd/api" - gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" - prCheckoutCmd "github.com/cli/cli/pkg/cmd/pr/checkout" - prDiffCmd "github.com/cli/cli/pkg/cmd/pr/diff" - prReviewCmd "github.com/cli/cli/pkg/cmd/pr/review" - repoCmd "github.com/cli/cli/pkg/cmd/repo" - repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" - repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" - creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" - repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork" - repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" - "github.com/google/shlex" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" ) // Version is dynamically set by the toolchain or overridden by the Makefile. @@ -47,192 +18,12 @@ var Version = "DEV" // BuildDate is dynamically set at build time in the Makefile. var BuildDate = "" // YYYY-MM-DD -var versionOutput = "" - -var defaultStreams *iostreams.IOStreams - func init() { if Version == "DEV" { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { Version = info.Main.Version } } - Version = strings.TrimPrefix(Version, "v") - if BuildDate == "" { - RootCmd.Version = Version - } else { - RootCmd.Version = fmt.Sprintf("%s (%s)", Version, BuildDate) - } - versionOutput = fmt.Sprintf("gh version %s\n%s\n", RootCmd.Version, changelogURL(Version)) - RootCmd.AddCommand(versionCmd) - RootCmd.SetVersionTemplate(versionOutput) - - RootCmd.PersistentFlags().Bool("help", false, "Show help for command") - RootCmd.Flags().Bool("version", false, "Show gh version") - // TODO: - // RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output") - - RootCmd.SetHelpFunc(rootHelpFunc) - RootCmd.SetUsageFunc(rootUsageFunc) - - RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { - if err == pflag.ErrHelp { - return err - } - return &cmdutil.FlagError{Err: err} - }) - - defaultStreams = iostreams.System() - - // TODO: iron out how a factory incorporates context - cmdFactory := &cmdutil.Factory{ - IOStreams: defaultStreams, - HttpClient: func() (*http.Client, error) { - // TODO: decouple from `context` - ctx := context.New() - cfg, err := ctx.Config() - if err != nil { - return nil, err - } - - // TODO: avoid setting Accept header for `api` command - return httpClient(defaultStreams, cfg, true), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - // TODO: decouple from `context` - ctx := context.New() - return ctx.BaseRepo() - }, - Remotes: func() (context.Remotes, error) { - ctx := context.New() - return ctx.Remotes() - }, - Config: func() (config.Config, error) { - cfg, err := config.ParseDefaultConfig() - if errors.Is(err, os.ErrNotExist) { - cfg = config.NewBlankConfig() - } else if err != nil { - return nil, err - } - return cfg, nil - }, - Branch: func() (string, error) { - currentBranch, err := git.CurrentBranch() - if err != nil { - return "", fmt.Errorf("could not determine current branch: %w", err) - } - return currentBranch, nil - }, - } - RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil)) - - gistCmd := &cobra.Command{ - Use: "gist", - Short: "Create gists", - Long: `Work with GitHub gists.`, - } - RootCmd.AddCommand(gistCmd) - gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil)) - - resolvedBaseRepo := func() (ghrepo.Interface, error) { - httpClient, err := cmdFactory.HttpClient() - if err != nil { - return nil, err - } - - apiClient := api.NewClientFromHTTP(httpClient) - - ctx := context.New() - remotes, err := ctx.Remotes() - if err != nil { - return nil, err - } - repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") - if err != nil { - return nil, err - } - baseRepo, err := repoContext.BaseRepo() - if err != nil { - return nil, err - } - - return baseRepo, nil - } - - repoResolvingCmdFactory := *cmdFactory - - repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo - - RootCmd.AddCommand(repoCmd.Cmd) - repoCmd.Cmd.AddCommand(repoViewCmd.NewCmdView(&repoResolvingCmdFactory, nil)) - repoCmd.Cmd.AddCommand(repoForkCmd.NewCmdFork(&repoResolvingCmdFactory, nil)) - repoCmd.Cmd.AddCommand(repoCloneCmd.NewCmdClone(cmdFactory, nil)) - repoCmd.Cmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil)) - repoCmd.Cmd.AddCommand(creditsCmd.NewCmdRepoCredits(&repoResolvingCmdFactory, nil)) - - prCmd.AddCommand(prReviewCmd.NewCmdReview(&repoResolvingCmdFactory, nil)) - prCmd.AddCommand(prDiffCmd.NewCmdDiff(&repoResolvingCmdFactory, nil)) - prCmd.AddCommand(prCheckoutCmd.NewCmdCheckout(&repoResolvingCmdFactory, nil)) - - RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil)) -} - -// RootCmd is the entry point of command-line execution -var RootCmd = &cobra.Command{ - Use: "gh [flags]", - Short: "GitHub CLI", - Long: `Work seamlessly with GitHub from the command line.`, - - SilenceErrors: true, - SilenceUsage: true, - Example: heredoc.Doc(` - $ gh issue create - $ gh repo clone cli/cli - $ gh pr checkout 321 - `), - Annotations: map[string]string{ - "help:feedback": heredoc.Doc(` - Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7 - Open an issue using “gh issue create -R cli/cli” - `), - "help:environment": heredoc.Doc(` - GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being - prompted to authenticate and overrides any previously stored credentials. - - GH_REPO: specify the GitHub repository in "OWNER/REPO" format for commands that - otherwise operate on a local repository. - - GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use - for authoring text. - - BROWSER: the web browser to use for opening links. - - DEBUG: set to any value to enable verbose output to standard error. Include values "api" - or "oauth" to print detailed information about HTTP requests or authentication flow. - - GLAMOUR_STYLE: the style to use for rendering Markdown. See - https://github.com/charmbracelet/glamour#styles - - NO_COLOR: avoid printing ANSI escape sequences for color output. - `), - }, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - fmt.Print(versionOutput) - }, -} - -// overridden in tests -var initContext = func() context.Context { - ctx := context.New() - if repo := os.Getenv("GH_REPO"); repo != "" { - ctx.SetBaseRepo(repo) - } - return ctx } // BasicClient returns an API client for github.com only that borrows from but @@ -256,281 +47,8 @@ func BasicClient() (*api.Client, error) { return api.NewClient(opts...), nil } -func contextForCommand(cmd *cobra.Command) context.Context { - ctx := initContext() - if repo, err := cmd.Flags().GetString("repo"); err == nil && repo != "" { - ctx.SetBaseRepo(repo) - } - return ctx -} - -// generic authenticated HTTP client for commands -func httpClient(io *iostreams.IOStreams, cfg config.Config, setAccept bool) *http.Client { - var opts []api.ClientOption - if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, apiVerboseLog()) - } - - opts = append(opts, - api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), - api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - return fmt.Sprintf("token %s", token), nil - } - - hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) - token, err := cfg.Get(hostname, "oauth_token") - if token == "" { - var notFound *config.NotFoundError - // TODO: check if stdout is TTY too - if errors.As(err, ¬Found) && io.IsStdinTTY() { - // interactive OAuth flow - token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required") - } - if err != nil { - return "", err - } - if token == "" { - // TODO: instruct user how to manually authenticate - return "", fmt.Errorf("authentication required for %s", hostname) - } - } - - return fmt.Sprintf("token %s", token), nil - }), - ) - - if setAccept { - opts = append(opts, - api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) { - // antiope-preview: Checks - accept := "application/vnd.github.antiope-preview+json" - if ghinstance.IsEnterprise(req.URL.Hostname()) { - // shadow-cat-preview: Draft pull requests - accept += ", application/vnd.github.shadow-cat-preview" - } - return accept, nil - }), - ) - } - - return api.NewHTTPClient(opts...) -} - -// LEGACY; overridden in tests -var apiClientForContext = func(ctx context.Context) (*api.Client, error) { - cfg, err := ctx.Config() - if err != nil { - return nil, err - } - - http := httpClient(defaultStreams, cfg, true) - return api.NewClientFromHTTP(http), nil -} - func apiVerboseLog() api.ClientOption { logTraffic := strings.Contains(os.Getenv("DEBUG"), "api") colorize := utils.IsTerminal(os.Stderr) return api.VerboseLog(utils.NewColorable(os.Stderr), logTraffic, colorize) } - -func colorableOut(cmd *cobra.Command) io.Writer { - out := cmd.OutOrStdout() - if outFile, isFile := out.(*os.File); isFile { - return utils.NewColorable(outFile) - } - return out -} - -func colorableErr(cmd *cobra.Command) io.Writer { - err := cmd.ErrOrStderr() - if outFile, isFile := err.(*os.File); isFile { - return utils.NewColorable(outFile) - } - return err -} - -func changelogURL(version string) string { - path := "https://github.com/cli/cli" - r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`) - if !r.MatchString(version) { - return fmt.Sprintf("%s/releases/latest", path) - } - - url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) - return url -} - -func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Context) (ghrepo.Interface, error) { - repo, _ := cmd.Flags().GetString("repo") - if repo != "" { - baseRepo, err := ghrepo.FromFullName(repo) - if err != nil { - return nil, fmt.Errorf("argument error: %w", err) - } - return baseRepo, nil - } - - remotes, err := ctx.Remotes() - if err != nil { - return nil, err - } - - repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") - if err != nil { - return nil, err - } - - baseRepo, err := repoContext.BaseRepo() - if err != nil { - return nil, err - } - - return baseRepo, nil -} - -// TODO there is a parallel implementation for isolated commands -func formatRemoteURL(cmd *cobra.Command, repo ghrepo.Interface) string { - ctx := contextForCommand(cmd) - - var protocol string - cfg, err := ctx.Config() - if err != nil { - fmt.Fprintf(colorableErr(cmd), "%s failed to load config: %s. using defaults\n", utils.Yellow("!"), err) - } else { - protocol, _ = cfg.Get(repo.RepoHost(), "git_protocol") - } - - if protocol == "ssh" { - return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) - } - - return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) -} - -// TODO there is a parallel implementation for isolated commands -func determineEditor(cmd *cobra.Command) (string, error) { - editorCommand := os.Getenv("GH_EDITOR") - if editorCommand == "" { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return "", fmt.Errorf("could not read config: %w", err) - } - // TODO: consider supporting setting an editor per GHE host - editorCommand, _ = cfg.Get(ghinstance.Default(), "editor") - } - - return editorCommand, nil -} - -func ExecuteShellAlias(args []string) error { - externalCmd := exec.Command(args[0], args[1:]...) - externalCmd.Stderr = os.Stderr - externalCmd.Stdout = os.Stdout - externalCmd.Stdin = os.Stdin - preparedCmd := run.PrepareCmd(externalCmd) - - return preparedCmd.Run() -} - -var findSh = func() (string, error) { - shPath, err := exec.LookPath("sh") - if err == nil { - return shPath, nil - } - - if runtime.GOOS == "windows" { - winNotFoundErr := errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.") - // We can try and find a sh executable in a Git for Windows install - gitPath, err := exec.LookPath("git") - if err != nil { - return "", winNotFoundErr - } - - shPath = filepath.Join(filepath.Dir(gitPath), "..", "bin", "sh.exe") - _, err = os.Stat(shPath) - if err != nil { - return "", winNotFoundErr - } - - return shPath, nil - } - - return "", errors.New("unable to locate sh to execute shell alias with") -} - -// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The -// second return value indicates whether the alias should be executed in a new shell process instead -// of running gh itself. -func ExpandAlias(args []string) (expanded []string, isShell bool, err error) { - err = nil - isShell = false - expanded = []string{} - - if len(args) < 2 { - // the command is lacking a subcommand - return - } - - ctx := initContext() - cfg, err := ctx.Config() - if err != nil { - return - } - aliases, err := cfg.Aliases() - if err != nil { - return - } - - expansion, ok := aliases.Get(args[1]) - if ok { - if strings.HasPrefix(expansion, "!") { - isShell = true - shPath, shErr := findSh() - if shErr != nil { - err = shErr - return - } - - expanded = []string{shPath, "-c", expansion[1:]} - - if len(args[2:]) > 0 { - expanded = append(expanded, "--") - expanded = append(expanded, args[2:]...) - } - - return - } - - extraArgs := []string{} - for i, a := range args[2:] { - if !strings.Contains(expansion, "$") { - extraArgs = append(extraArgs, a) - } else { - expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a) - } - } - lingeringRE := regexp.MustCompile(`\$\d`) - if lingeringRE.MatchString(expansion) { - err = fmt.Errorf("not enough arguments for alias: %s", expansion) - return - } - - var newArgs []string - newArgs, err = shlex.Split(expansion) - if err != nil { - return - } - - expanded = append(newArgs, extraArgs...) - return - } - - expanded = args[1:] - return -} - -func connectedToTerminal(cmd *cobra.Command) bool { - return utils.IsTerminal(cmd.InOrStdin()) && utils.IsTerminal(cmd.OutOrStdout()) -} diff --git a/command/testing.go b/command/testing.go deleted file mode 100644 index 367d510bb..000000000 --- a/command/testing.go +++ /dev/null @@ -1,125 +0,0 @@ -package command - -import ( - "bytes" - "fmt" - - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/utils" - "github.com/google/shlex" - "github.com/spf13/pflag" -) - -const defaultTestConfig = `hosts: - github.com: - user: OWNER - oauth_token: "1234567890" -` - -func initBlankContext(cfg, repo, branch string) { - initContext = func() context.Context { - ctx := context.NewBlank() - ctx.SetBaseRepo(repo) - ctx.SetBranch(branch) - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - - if cfg == "" { - cfg = defaultTestConfig - } - - // NOTE we are not restoring the original readConfig; we never want to touch the config file on - // disk during tests. - config.StubConfig(cfg, "") - - return ctx - } -} - -func initFakeHTTP() *httpmock.Registry { - http := &httpmock.Registry{} - apiClientForContext = func(context.Context) (*api.Client, error) { - return api.NewClient(api.ReplaceTripper(http)), nil - } - return http -} - -type cmdOut struct { - outBuf, errBuf *bytes.Buffer -} - -func (c cmdOut) String() string { - return c.outBuf.String() -} - -func (c cmdOut) Stderr() string { - return c.errBuf.String() -} - -func RunCommand(args string) (*cmdOut, error) { - rootCmd := RootCmd - rootArgv, err := shlex.Split(args) - if err != nil { - return nil, err - } - - cmd, _, err := rootCmd.Traverse(rootArgv) - if err != nil { - return nil, err - } - - rootCmd.SetArgs(rootArgv) - - outBuf := bytes.Buffer{} - cmd.SetOut(&outBuf) - errBuf := bytes.Buffer{} - cmd.SetErr(&errBuf) - - // Reset flag values so they don't leak between tests - // FIXME: change how we initialize Cobra commands to render this hack unnecessary - cmd.Flags().VisitAll(func(f *pflag.Flag) { - f.Changed = false - switch v := f.Value.(type) { - case pflag.SliceValue: - _ = v.Replace([]string{}) - default: - switch v.Type() { - case "bool", "string", "int": - _ = v.Set(f.DefValue) - } - } - }) - - _, err = rootCmd.ExecuteC() - cmd.SetOut(nil) - cmd.SetErr(nil) - - return &cmdOut{&outBuf, &errBuf}, err -} - -func stubTerminal(connected bool) func() { - isTerminal := utils.IsTerminal - utils.IsTerminal = func(_ interface{}) bool { - return connected - } - - terminalSize := utils.TerminalSize - if connected { - utils.TerminalSize = func(_ interface{}) (int, int, error) { - return 80, 20, nil - } - } else { - utils.TerminalSize = func(_ interface{}) (int, int, error) { - return 0, 0, fmt.Errorf("terminal connection stubbed to false") - } - } - - return func() { - utils.IsTerminal = isTerminal - utils.TerminalSize = terminalSize - } -} diff --git a/context/blank_context.go b/context/blank_context.go index f14310937..96f599981 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -2,11 +2,8 @@ package context import ( "fmt" - "strings" - "github.com/cli/cli/git" "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghrepo" ) // NewBlank initializes a blank Context suitable for testing @@ -16,9 +13,6 @@ func NewBlank() *blankContext { // A Context implementation that queries the filesystem type blankContext struct { - branch string - baseRepo ghrepo.Interface - remotes Remotes } func (c *blankContext) Config() (config.Config, error) { @@ -28,51 +22,3 @@ func (c *blankContext) Config() (config.Config, error) { } return cfg, nil } - -func (c *blankContext) Branch() (string, error) { - if c.branch == "" { - return "", fmt.Errorf("branch was not initialized: %w", git.ErrNotOnAnyBranch) - } - return c.branch, nil -} - -func (c *blankContext) SetBranch(b string) { - c.branch = b -} - -func (c *blankContext) Remotes() (Remotes, error) { - if c.remotes == nil { - return nil, fmt.Errorf("remotes were not initialized") - } - return c.remotes, nil -} - -func (c *blankContext) SetRemotes(stubs map[string]string) { - c.remotes = make([]*Remote, 0, len(stubs)) - for remoteName, repo := range stubs { - ownerWithName := strings.SplitN(repo, "/", 2) - c.remotes = append(c.remotes, &Remote{ - Remote: &git.Remote{Name: remoteName}, - Repo: ghrepo.New(ownerWithName[0], ownerWithName[1]), - }) - } -} - -func (c *blankContext) BaseRepo() (ghrepo.Interface, error) { - if c.baseRepo != nil { - return c.baseRepo, nil - } - remotes, err := c.Remotes() - if err != nil { - return nil, err - } - if len(remotes) < 1 { - return nil, fmt.Errorf("remotes are empty") - } - return remotes[0], nil -} - -func (c *blankContext) SetBaseRepo(nwo string) { - repo, _ := ghrepo.FromFullName(nwo) - c.baseRepo = repo -} diff --git a/context/context.go b/context/context.go index 6cff06b4c..3bcffd2f5 100644 --- a/context/context.go +++ b/context/context.go @@ -8,18 +8,12 @@ import ( "strings" "github.com/cli/cli/api" - "github.com/cli/cli/git" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" ) // Context represents the interface for querying information about the current environment type Context interface { - Branch() (string, error) - SetBranch(string) - Remotes() (Remotes, error) - BaseRepo() (ghrepo.Interface, error) - SetBaseRepo(string) Config() (config.Config, error) } @@ -46,7 +40,7 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re continue } repos = append(repos, r) - if ghrepo.IsSame(r, baseOverride) { + if baseOverride != nil && ghrepo.IsSame(r, baseOverride) { foundBaseOverride = true } if len(repos) == maxRemotesForLookup { @@ -159,10 +153,7 @@ func New() Context { // A Context implementation that queries the filesystem type fsContext struct { - config config.Config - remotes Remotes - branch string - baseRepo ghrepo.Interface + config config.Config } func (c *fsContext) Config() (config.Config, error) { @@ -177,79 +168,3 @@ func (c *fsContext) Config() (config.Config, error) { } return c.config, nil } - -func (c *fsContext) Branch() (string, error) { - if c.branch != "" { - return c.branch, nil - } - - currentBranch, err := git.CurrentBranch() - if err != nil { - return "", fmt.Errorf("could not determine current branch: %w", err) - } - - c.branch = currentBranch - return c.branch, nil -} - -func (c *fsContext) SetBranch(b string) { - c.branch = b -} - -func (c *fsContext) Remotes() (Remotes, error) { - if c.remotes == nil { - gitRemotes, err := git.Remotes() - if err != nil { - return nil, err - } - if len(gitRemotes) == 0 { - return nil, errors.New("no git remotes found") - } - - sshTranslate := git.ParseSSHConfig().Translator() - resolvedRemotes := translateRemotes(gitRemotes, sshTranslate) - - // determine hostname by looking at the "main" remote - var hostname string - if mainRemote, err := resolvedRemotes.FindByName("upstream", "github", "origin", "*"); err == nil { - hostname = mainRemote.RepoHost() - } - - // filter the rest of the remotes to just that hostname - filteredRemotes := Remotes{} - for _, r := range resolvedRemotes { - if r.RepoHost() != hostname { - continue - } - filteredRemotes = append(filteredRemotes, r) - } - c.remotes = filteredRemotes - } - - if len(c.remotes) == 0 { - return nil, errors.New("no git remote found for a github.com repository") - } - return c.remotes, nil -} - -func (c *fsContext) BaseRepo() (ghrepo.Interface, error) { - if c.baseRepo != nil { - return c.baseRepo, nil - } - - remotes, err := c.Remotes() - if err != nil { - return nil, err - } - rem, err := remotes.FindByName("upstream", "github", "origin", "*") - if err != nil { - return nil, err - } - - c.baseRepo = rem - return c.baseRepo, nil -} - -func (c *fsContext) SetBaseRepo(nwo string) { - c.baseRepo, _ = ghrepo.FromFullName(nwo) -} diff --git a/context/remote.go b/context/remote.go index 79ba0e8c8..b88878483 100644 --- a/context/remote.go +++ b/context/remote.go @@ -76,7 +76,7 @@ func (r Remote) RepoHost() string { } // TODO: accept an interface instead of git.RemoteSet -func translateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) { +func TranslateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) { for _, r := range gitRemotes { var repo ghrepo.Interface if r.FetchURL != nil { diff --git a/context/remote_test.go b/context/remote_test.go index 6d5801e26..98326b3aa 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -57,7 +57,7 @@ func Test_translateRemotes(t *testing.T) { identityURL := func(u *url.URL) *url.URL { return u } - result := translateRemotes(gitRemotes, identityURL) + result := TranslateRemotes(gitRemotes, identityURL) if len(result) != 1 { t.Errorf("got %d results", len(result)) diff --git a/docs/source.md b/docs/source.md index 0c4e3db63..951731f8f 100644 --- a/docs/source.md +++ b/docs/source.md @@ -1,6 +1,6 @@ # Installation from source -0. Verify that you have Go 1.13.8+ installed +0. Verify that you have Go 1.15+ installed ```sh $ go version diff --git a/git/remote.go b/git/remote.go index ab6cdb9fb..e808c1492 100644 --- a/git/remote.go +++ b/git/remote.go @@ -14,6 +14,15 @@ var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) // RemoteSet is a slice of git remotes type RemoteSet []*Remote +func NewRemote(name string, u string) *Remote { + pu, _ := url.Parse(u) + return &Remote{ + Name: name, + FetchURL: pu, + PushURL: pu, + } +} + // Remote is a parsed git remote type Remote struct { Name string diff --git a/git/url.go b/git/url.go index 55e11c08f..51938f934 100644 --- a/git/url.go +++ b/git/url.go @@ -10,6 +10,10 @@ var ( protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://") ) +func IsURL(u string) bool { + return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u) +} + // ParseURL normalizes git remote urls func ParseURL(rawURL string) (u *url.URL, err error) { if !protocolRe.MatchString(rawURL) && diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 2f97d2956..d4c132360 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -75,22 +75,30 @@ func parseConfigFile(filename string) ([]byte, *yaml.Node, error) { return nil, nil, err } - var root yaml.Node - err = yaml.Unmarshal(data, &root) + root, err := parseConfigData(data) if err != nil { - return data, nil, err + return nil, nil, err } + return data, root, err +} + +func parseConfigData(data []byte) (*yaml.Node, error) { + var root yaml.Node + err := yaml.Unmarshal(data, &root) + if err != nil { + return nil, err + } + if len(root.Content) == 0 { - return data, &yaml.Node{ + return &yaml.Node{ Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}, }, nil } if root.Content[0].Kind != yaml.MappingNode { - return data, &root, fmt.Errorf("expected a top level map") + return &root, fmt.Errorf("expected a top level map") } - - return data, &root, nil + return &root, nil } func isLegacy(root *yaml.Node) bool { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index f921d2c67..2d7fd4b73 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -2,7 +2,6 @@ package config import ( "bytes" - "errors" "fmt" "reflect" "testing" @@ -71,17 +70,29 @@ github.com: eq(t, token, "OTOKEN") } -func Test_parseConfig_notFound(t *testing.T) { +func Test_parseConfig_hostFallback(t *testing.T) { defer StubConfig(`--- -hosts: - example.com: +git_protocol: ssh +`, `--- +github.com: + user: monalisa + oauth_token: OTOKEN +example.com: user: wronguser oauth_token: NOTTHIS -`, "")() + git_protocol: https +`)() config, err := ParseConfig("config.yml") eq(t, err, nil) - _, err = config.Get("github.com", "user") - eq(t, err, &NotFoundError{errors.New(`could not find config entry for "github.com"`)}) + val, err := config.Get("example.com", "git_protocol") + eq(t, err, nil) + eq(t, val, "https") + val, err = config.Get("github.com", "git_protocol") + eq(t, err, nil) + eq(t, val, "ssh") + val, err = config.Get("nonexist.io", "git_protocol") + eq(t, err, nil) + eq(t, val, "ssh") } func Test_ParseConfig_migrateConfig(t *testing.T) { diff --git a/internal/config/config_setup.go b/internal/config/config_setup.go index 0d40e1a0f..2aaf597c8 100644 --- a/internal/config/config_setup.go +++ b/internal/config/config_setup.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/auth" + "github.com/cli/cli/utils" ) var ( @@ -25,8 +26,8 @@ func IsGitHubApp(id string) bool { return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f" } -func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) { - token, userLogin, err := authFlow(hostname, notice) +func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) { + token, userLogin, err := authFlow(hostname, notice, additionalScopes) if err != nil { return "", err } @@ -49,17 +50,20 @@ func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) { return token, nil } -func authFlow(oauthHost, notice string) (string, string, error) { +func authFlow(oauthHost, notice string, additionalScopes []string) (string, string, error) { var verboseStream io.Writer if strings.Contains(os.Getenv("DEBUG"), "oauth") { verboseStream = os.Stderr } + minimumScopes := []string{"repo", "read:org", "gist"} + scopes := append(minimumScopes, additionalScopes...) + flow := &auth.OAuthFlow{ Hostname: oauthHost, ClientID: oauthClientID, ClientSecret: oauthClientSecret, - Scopes: []string{"repo", "read:org", "gist"}, + Scopes: scopes, WriteSuccessHTML: func(w io.Writer) { fmt.Fprintln(w, oauthSuccessPage) }, @@ -67,7 +71,7 @@ func authFlow(oauthHost, notice string) (string, string, error) { } fmt.Fprintln(os.Stderr, notice) - fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname) + fmt.Fprintf(os.Stderr, "- %s to open %s in your browser... ", utils.Bold("Press Enter"), flow.Hostname) _ = waitForEnter(os.Stdin) token, err := flow.ObtainAccessToken() if err != nil { @@ -83,7 +87,8 @@ func authFlow(oauthHost, notice string) (string, string, error) { } func AuthFlowComplete() { - fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ") + fmt.Fprintf(os.Stderr, "%s Authentication complete. %s to continue...\n", + utils.GreenCheck(), utils.Bold("Press Enter")) _ = waitForEnter(os.Stdin) } diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 85e5c0d4a..28d91f9b0 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -4,7 +4,9 @@ import ( "bytes" "errors" "fmt" + "sort" + "github.com/cli/cli/internal/ghinstance" "gopkg.in/yaml.v3" ) @@ -14,6 +16,8 @@ const defaultGitProtocol = "https" type Config interface { Get(string, string) (string, error) Set(string, string, string) error + UnsetHost(string) + Hosts() ([]string, error) Aliases() (*AliasConfig, error) Write() error } @@ -29,7 +33,7 @@ type HostConfig struct { // This type implements a low-level get/set config that is backed by an in-memory tree of Yaml // nodes. It allows us to interact with a yaml-based config programmatically, preserving any -// comments that were present when the yaml waas parsed. +// comments that were present when the yaml was parsed. type ConfigMap struct { Root *yaml.Node } @@ -122,6 +126,16 @@ func NewConfig(root *yaml.Node) Config { } } +// NewFromString initializes a Config from a yaml string +func NewFromString(str string) Config { + root, err := parseConfigData([]byte(str)) + if err != nil { + panic(err) + } + return NewConfig(root) +} + +// NewBlankConfig initializes a config file pre-populated with comments and default values func NewBlankConfig() Config { return NewConfig(NewBlankRoot()) } @@ -187,16 +201,19 @@ func (c *fileConfig) Root() *yaml.Node { func (c *fileConfig) Get(hostname, key string) (string, error) { if hostname != "" { + var notFound *NotFoundError + hostCfg, err := c.configForHost(hostname) - if err != nil { + if err != nil && !errors.As(err, ¬Found) { return "", err } - hostValue, err := hostCfg.GetStringValue(key) - var notFound *NotFoundError - - if err != nil && !errors.As(err, ¬Found) { - return "", err + var hostValue string + if hostCfg != nil { + hostValue, err = hostCfg.GetStringValue(key) + if err != nil && !errors.As(err, ¬Found) { + return "", err + } } if hostValue != "" { @@ -236,6 +253,20 @@ func (c *fileConfig) Set(hostname, key, value string) error { } } +func (c *fileConfig) UnsetHost(hostname string) { + if hostname == "" { + return + } + + hostsEntry, err := c.FindEntry("hosts") + if err != nil { + return + } + + cm := ConfigMap{hostsEntry.ValueNode} + cm.RemoveEntry(hostname) +} + func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) { hosts, err := c.hostEntries() if err != nil { @@ -357,6 +388,23 @@ func (c *fileConfig) hostEntries() ([]*HostConfig, error) { return hostConfigs, nil } +// Hosts returns a list of all known hostnames configured in hosts.yml +func (c *fileConfig) Hosts() ([]string, error) { + entries, err := c.hostEntries() + if err != nil { + return nil, err + } + + hostnames := []string{} + for _, entry := range entries { + hostnames = append(hostnames, entry.Host) + } + + sort.SliceStable(hostnames, func(i, j int) bool { return hostnames[i] == ghinstance.Default() }) + + return hostnames, nil +} + func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig { hostRoot := &yaml.Node{Kind: yaml.MappingNode} hostCfg := &HostConfig{ diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index c05c3b263..560cc9c5c 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -7,11 +7,27 @@ import ( const defaultHostname = "github.com" +var hostnameOverride string + // Default returns the host name of the default GitHub instance func Default() string { return defaultHostname } +// OverridableDefault is like Default, except it is overridable by the GH_HOST environment variable +func OverridableDefault() string { + if hostnameOverride != "" { + return hostnameOverride + } + return defaultHostname +} + +// OverrideDefault overrides the value returned from OverridableDefault. This should only ever be +// called from the main runtime path, not tests. +func OverrideDefault(newhost string) { + hostnameOverride = newhost +} + // IsEnterprise reports whether a non-normalized host name looks like a GHE instance func IsEnterprise(h string) bool { return NormalizeHostname(h) != defaultHostname diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 26515dc39..10a40a432 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -4,6 +4,29 @@ import ( "testing" ) +func TestOverridableDefault(t *testing.T) { + oldOverride := hostnameOverride + t.Cleanup(func() { + hostnameOverride = oldOverride + }) + + host := OverridableDefault() + if host != "github.com" { + t.Errorf("expected github.com, got %q", host) + } + + OverrideDefault("example.org") + + host = OverridableDefault() + if host != "example.org" { + t.Errorf("expected example.org, got %q", host) + } + host = Default() + if host != "github.com" { + t.Errorf("expected github.com, got %q", host) + } +} + func TestIsEnterprise(t *testing.T) { tests := []struct { host string diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index b80b09219..86fb74d67 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -4,9 +4,10 @@ import ( "fmt" "net/url" "strings" -) -const defaultHostname = "github.com" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/ghinstance" +) // Interface describes an object that represents a GitHub repository type Interface interface { @@ -17,10 +18,7 @@ type Interface interface { // New instantiates a GitHub repository from owner and name arguments func New(owner, repo string) Interface { - return &ghRepo{ - owner: owner, - name: repo, - } + return NewWithHost(owner, repo, ghinstance.OverridableDefault()) } // NewWithHost is like New with an explicit host name @@ -28,7 +26,7 @@ func NewWithHost(owner, repo, hostname string) Interface { return &ghRepo{ owner: owner, name: repo, - hostname: hostname, + hostname: normalizeHostname(hostname), } } @@ -37,15 +35,31 @@ func FullName(r Interface) string { return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName()) } -// FromFullName extracts the GitHub repository information from an "OWNER/REPO" string +// FromFullName extracts the GitHub repository information from the following +// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. func FromFullName(nwo string) (Interface, error) { - var r ghRepo - parts := strings.SplitN(nwo, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return &r, fmt.Errorf("expected OWNER/REPO format, got %q", nwo) + if git.IsURL(nwo) { + u, err := git.ParseURL(nwo) + if err != nil { + return nil, err + } + return FromURL(u) + } + + parts := strings.SplitN(nwo, "/", 4) + for _, p := range parts { + if len(p) == 0 { + return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo) + } + } + switch len(parts) { + case 3: + return NewWithHost(parts[1], parts[2], normalizeHostname(parts[0])), nil + case 2: + return New(parts[0], parts[1]), nil + default: + return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo) } - r.owner, r.name = parts[0], parts[1] - return &r, nil } // FromURL extracts the GitHub repository information from a git remote URL @@ -59,11 +73,7 @@ func FromURL(u *url.URL) (Interface, error) { return nil, fmt.Errorf("invalid path: %s", u.Path) } - return &ghRepo{ - owner: parts[0], - name: strings.TrimSuffix(parts[1], ".git"), - hostname: normalizeHostname(u.Hostname()), - }, nil + return NewWithHost(parts[0], strings.TrimSuffix(parts[1], ".git"), u.Hostname()), nil } func normalizeHostname(h string) string { @@ -109,8 +119,5 @@ func (r ghRepo) RepoName() string { } func (r ghRepo) RepoHost() string { - if r.hostname != "" { - return r.hostname - } - return defaultHostname + return r.hostname } diff --git a/internal/ghrepo/repo_test.go b/internal/ghrepo/repo_test.go index 3c421bd64..7d796f1cb 100644 --- a/internal/ghrepo/repo_test.go +++ b/internal/ghrepo/repo_test.go @@ -114,3 +114,87 @@ func Test_repoFromURL(t *testing.T) { }) } } + +func TestFromFullName(t *testing.T) { + tests := []struct { + name string + input string + wantOwner string + wantName string + wantHost string + wantErr error + }{ + { + name: "OWNER/REPO combo", + input: "OWNER/REPO", + wantHost: "github.com", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, + { + name: "too few elements", + input: "OWNER", + wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "OWNER"`), + }, + { + name: "too many elements", + input: "a/b/c/d", + wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/b/c/d"`), + }, + { + name: "blank value", + input: "a/", + wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/"`), + }, + { + name: "with hostname", + input: "example.org/OWNER/REPO", + wantHost: "example.org", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, + { + name: "full URL", + input: "https://example.org/OWNER/REPO.git", + wantHost: "example.org", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, + { + name: "SSH URL", + input: "git@example.org:OWNER/REPO.git", + wantHost: "example.org", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := FromFullName(tt.input) + if tt.wantErr != nil { + if err == nil { + t.Fatalf("no error in result, expected %v", tt.wantErr) + } else if err.Error() != tt.wantErr.Error() { + t.Fatalf("expected error %q, got %q", tt.wantErr.Error(), err.Error()) + } + return + } + if err != nil { + t.Fatalf("got error %v", err) + } + if r.RepoHost() != tt.wantHost { + t.Errorf("expected host %q, got %q", tt.wantHost, r.RepoHost()) + } + if r.RepoOwner() != tt.wantOwner { + t.Errorf("expected owner %q, got %q", tt.wantOwner, r.RepoOwner()) + } + if r.RepoName() != tt.wantName { + t.Errorf("expected name %q, got %q", tt.wantName, r.RepoName()) + } + }) + } +} diff --git a/pkg/cmd/alias/alias.go b/pkg/cmd/alias/alias.go new file mode 100644 index 000000000..20bc26e83 --- /dev/null +++ b/pkg/cmd/alias/alias.go @@ -0,0 +1,28 @@ +package alias + +import ( + "github.com/MakeNowJust/heredoc" + deleteCmd "github.com/cli/cli/pkg/cmd/alias/delete" + listCmd "github.com/cli/cli/pkg/cmd/alias/list" + setCmd "github.com/cli/cli/pkg/cmd/alias/set" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdAlias(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "Create command shortcuts", + Long: heredoc.Doc(` + Aliases can be used to make shortcuts for gh commands or to compose multiple commands. + + Run "gh help alias set" to learn more. + `), + } + + cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil)) + cmd.AddCommand(listCmd.NewCmdList(f, nil)) + cmd.AddCommand(setCmd.NewCmdSet(f, nil)) + + return cmd +} diff --git a/pkg/cmd/alias/delete/delete.go b/pkg/cmd/alias/delete/delete.go new file mode 100644 index 000000000..ccf98ca68 --- /dev/null +++ b/pkg/cmd/alias/delete/delete.go @@ -0,0 +1,71 @@ +package delete + +import ( + "fmt" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams + + Name string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an alias", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return fmt.Errorf("couldn't read aliases config: %w", err) + } + + expansion, ok := aliasCfg.Get(opts.Name) + if !ok { + return fmt.Errorf("no such alias %s", opts.Name) + + } + + err = aliasCfg.Delete(opts.Name) + if err != nil { + return fmt.Errorf("failed to delete alias %s: %w", opts.Name, err) + } + + if opts.IO.IsStdoutTTY() { + redCheck := utils.Red("✓") + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", redCheck, opts.Name, expansion) + } + + return nil +} diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go new file mode 100644 index 000000000..3c6acea28 --- /dev/null +++ b/pkg/cmd/alias/delete/delete_test.go @@ -0,0 +1,90 @@ +package delete + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAliasDelete(t *testing.T) { + tests := []struct { + name string + config string + cli string + isTTY bool + wantStdout string + wantStderr string + wantErr string + }{ + { + name: "no aliases", + config: "", + cli: "co", + isTTY: true, + wantStdout: "", + wantStderr: "", + wantErr: "no such alias co", + }, + { + name: "delete one", + config: heredoc.Doc(` + aliases: + il: issue list + co: pr checkout + `), + cli: "co", + isTTY: true, + wantStdout: "", + wantStderr: "✓ Deleted alias co; was pr checkout\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(tt.config) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdDelete(factory, nil) + + argv, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + if assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err.Error()) + } + return + } + require.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/alias/expand/expand.go b/pkg/cmd/alias/expand/expand.go new file mode 100644 index 000000000..b2117f198 --- /dev/null +++ b/pkg/cmd/alias/expand/expand.go @@ -0,0 +1,106 @@ +package expand + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/cli/cli/internal/config" + "github.com/google/shlex" +) + +// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The +// second return value indicates whether the alias should be executed in a new shell process instead +// of running gh itself. +func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) { + if len(args) < 2 { + // the command is lacking a subcommand + return + } + expanded = args[1:] + + aliases, err := cfg.Aliases() + if err != nil { + return + } + + expansion, ok := aliases.Get(args[1]) + if !ok { + return + } + + if strings.HasPrefix(expansion, "!") { + isShell = true + if findShFunc == nil { + findShFunc = findSh + } + shPath, shErr := findShFunc() + if shErr != nil { + err = shErr + return + } + + expanded = []string{shPath, "-c", expansion[1:]} + + if len(args[2:]) > 0 { + expanded = append(expanded, "--") + expanded = append(expanded, args[2:]...) + } + + return + } + + extraArgs := []string{} + for i, a := range args[2:] { + if !strings.Contains(expansion, "$") { + extraArgs = append(extraArgs, a) + } else { + expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a) + } + } + lingeringRE := regexp.MustCompile(`\$\d`) + if lingeringRE.MatchString(expansion) { + err = fmt.Errorf("not enough arguments for alias: %s", expansion) + return + } + + var newArgs []string + newArgs, err = shlex.Split(expansion) + if err != nil { + return + } + + expanded = append(newArgs, extraArgs...) + return +} + +func findSh() (string, error) { + shPath, err := exec.LookPath("sh") + if err == nil { + return shPath, nil + } + + if runtime.GOOS == "windows" { + winNotFoundErr := errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.") + // We can try and find a sh executable in a Git for Windows install + gitPath, err := exec.LookPath("git") + if err != nil { + return "", winNotFoundErr + } + + shPath = filepath.Join(filepath.Dir(gitPath), "..", "bin", "sh.exe") + _, err = os.Stat(shPath) + if err != nil { + return "", winNotFoundErr + } + + return shPath, nil + } + + return "", errors.New("unable to locate sh to execute shell alias with") +} diff --git a/pkg/cmd/alias/expand/expand_test.go b/pkg/cmd/alias/expand/expand_test.go new file mode 100644 index 000000000..b9535f7c7 --- /dev/null +++ b/pkg/cmd/alias/expand/expand_test.go @@ -0,0 +1,185 @@ +package expand + +import ( + "errors" + "reflect" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" +) + +func TestExpandAlias(t *testing.T) { + findShFunc := func() (string, error) { + return "/usr/bin/sh", nil + } + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + co: pr checkout + il: issue list --author="$1" --label="$2" + ia: issue list --author="$1" --assignee="$1" + `)) + + type args struct { + config config.Config + argv []string + } + tests := []struct { + name string + args args + wantExpanded []string + wantIsShell bool + wantErr error + }{ + { + name: "no arguments", + args: args{ + config: cfg, + argv: []string{}, + }, + wantExpanded: []string(nil), + wantIsShell: false, + wantErr: nil, + }, + { + name: "too few arguments", + args: args{ + config: cfg, + argv: []string{"gh"}, + }, + wantExpanded: []string(nil), + wantIsShell: false, + wantErr: nil, + }, + { + name: "no expansion", + args: args{ + config: cfg, + argv: []string{"gh", "pr", "status"}, + }, + wantExpanded: []string{"pr", "status"}, + wantIsShell: false, + wantErr: nil, + }, + { + name: "simple expansion", + args: args{ + config: cfg, + argv: []string{"gh", "co"}, + }, + wantExpanded: []string{"pr", "checkout"}, + wantIsShell: false, + wantErr: nil, + }, + { + name: "adding arguments after expansion", + args: args{ + config: cfg, + argv: []string{"gh", "co", "123"}, + }, + wantExpanded: []string{"pr", "checkout", "123"}, + wantIsShell: false, + wantErr: nil, + }, + { + name: "not enough arguments for expansion", + args: args{ + config: cfg, + argv: []string{"gh", "il"}, + }, + wantExpanded: []string{}, + wantIsShell: false, + wantErr: errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`), + }, + { + name: "not enough arguments for expansion 2", + args: args{ + config: cfg, + argv: []string{"gh", "il", "vilmibm"}, + }, + wantExpanded: []string{}, + wantIsShell: false, + wantErr: errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`), + }, + { + name: "satisfy expansion arguments", + args: args{ + config: cfg, + argv: []string{"gh", "il", "vilmibm", "help wanted"}, + }, + wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"}, + wantIsShell: false, + wantErr: nil, + }, + { + name: "mixed positional and non-positional arguments", + args: args{ + config: cfg, + argv: []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"}, + }, + wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"}, + wantIsShell: false, + wantErr: nil, + }, + { + name: "dollar in expansion", + args: args{ + config: cfg, + argv: []string{"gh", "ia", "$coolmoney$"}, + }, + wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"}, + wantIsShell: false, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc) + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error") + } + if tt.wantErr.Error() != err.Error() { + t.Fatalf("expected error %q, got %q", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("got error: %v", err) + } + if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) { + t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded) + } + if gotIsShell != tt.wantIsShell { + t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell) + } + }) + } +} + +// cfg := `--- +// aliases: +// co: pr checkout +// il: issue list --author="$1" --label="$2" +// ia: issue list --author="$1" --assignee="$1" +// ` +// initBlankContext(cfg, "OWNER/REPO", "trunk") +// for _, c := range []struct { +// Args string +// ExpectedArgs []string +// Err string +// }{ +// {"gh co", []string{"pr", "checkout"}, ""}, +// {"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`}, +// {"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`}, +// {"gh co 123", []string{"pr", "checkout", "123"}, ""}, +// {"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""}, +// {"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""}, +// {"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""}, +// {"gh pr status", []string{"pr", "status"}, ""}, +// {"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""}, +// {"gh dne", []string{"dne"}, ""}, +// {"gh", []string{}, ""}, +// {"", []string{}, ""}, +// } { diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go new file mode 100644 index 000000000..b9d67c2ae --- /dev/null +++ b/pkg/cmd/alias/list/list.go @@ -0,0 +1,83 @@ +package list + +import ( + "fmt" + "sort" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List your aliases", + Long: heredoc.Doc(` + This command prints out all of the aliases gh is configured to use. + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return fmt.Errorf("couldn't read aliases config: %w", err) + } + + if aliasCfg.Empty() { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "no aliases configured\n") + } + return nil + } + + tp := utils.NewTablePrinter(opts.IO) + + aliasMap := aliasCfg.All() + keys := []string{} + for alias := range aliasMap { + keys = append(keys, alias) + } + sort.Strings(keys) + + for _, alias := range keys { + if tp.IsTTY() { + // ensure that screen readers pause + tp.AddField(alias+":", nil, nil) + } else { + tp.AddField(alias, nil, nil) + } + tp.AddField(aliasMap[alias], nil, nil) + tp.EndRow() + } + + return tp.Render() +} diff --git a/pkg/cmd/alias/list/list_test.go b/pkg/cmd/alias/list/list_test.go new file mode 100644 index 000000000..b058a04ce --- /dev/null +++ b/pkg/cmd/alias/list/list_test.go @@ -0,0 +1,77 @@ +package list + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAliasList(t *testing.T) { + tests := []struct { + name string + config string + isTTY bool + wantStdout string + wantStderr string + }{ + { + name: "empty", + config: "", + isTTY: true, + wantStdout: "", + wantStderr: "no aliases configured\n", + }, + { + name: "some", + config: heredoc.Doc(` + aliases: + co: pr checkout + gc: "!gh gist create \"$@\" | pbcopy" + `), + isTTY: true, + wantStdout: "co: pr checkout\ngc: !gh gist create \"$@\" | pbcopy\n", + wantStderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // TODO: change underlying config implementation so Write is not + // automatically called when editing aliases in-memory + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(tt.config) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdList(factory, nil) + cmd.SetArgs([]string{}) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go new file mode 100644 index 000000000..a9f628471 --- /dev/null +++ b/pkg/cmd/alias/set/set.go @@ -0,0 +1,147 @@ +package set + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/google/shlex" + "github.com/spf13/cobra" +) + +type SetOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams + + Name string + Expansion string + IsShell bool + RootCmd *cobra.Command +} + +func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { + opts := &SetOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "set ", + Short: "Create a shortcut for a gh command", + Long: heredoc.Doc(` + Declare a word as a command alias that will expand to the specified command(s). + + The expansion may specify additional arguments and flags. If the expansion + includes positional placeholders such as '$1', '$2', etc., any extra arguments + that follow the invocation of an alias will be inserted appropriately. + + If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you + to compose commands with "|" or redirect with ">". Note that extra arguments following the alias + will not be automatically passed to the expanded expression. To have a shell alias receive + arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them. + + Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If + you have installed git on Windows in some other way, shell aliases may not work for you. + + Quotes must always be used when defining a command as in the examples. + `), + Example: heredoc.Doc(` + $ gh alias set pv 'pr view' + $ gh pv -w 123 + #=> gh pr view -w 123 + + $ gh alias set bugs 'issue list --label="bugs"' + + $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' + $ gh epicsBy vilmibm + #=> gh issue list --author="vilmibm" --label="epic" + + $ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2' + $ gh igrep epic foo + #=> gh issue list --label="epic" | grep "foo" + `), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.RootCmd = cmd.Root() + + opts.Name = args[0] + opts.Expansion = args[1] + + if runF != nil { + return runF(opts) + } + return setRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.IsShell, "shell", "s", false, "Declare an alias to be passed through a shell interpreter") + + return cmd +} + +func setRun(opts *SetOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return err + } + + isTerminal := opts.IO.IsStdoutTTY() + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", utils.Bold(opts.Name), utils.Bold(opts.Expansion)) + } + + expansion := opts.Expansion + isShell := opts.IsShell + if isShell && !strings.HasPrefix(expansion, "!") { + expansion = "!" + expansion + } + isShell = strings.HasPrefix(expansion, "!") + + if validCommand(opts.RootCmd, opts.Name) { + return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name) + } + + if !isShell && !validCommand(opts.RootCmd, expansion) { + return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) + } + + successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) + if oldExpansion, ok := aliasCfg.Get(opts.Name); ok { + successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", + utils.Green("✓"), + utils.Bold(opts.Name), + utils.Bold(oldExpansion), + utils.Bold(expansion), + ) + } + + err = aliasCfg.Add(opts.Name, expansion) + if err != nil { + return fmt.Errorf("could not create alias: %s", err) + } + + if isTerminal { + fmt.Fprintln(opts.IO.ErrOut, successMsg) + } + + return nil +} + +func validCommand(rootCmd *cobra.Command, expansion string) bool { + split, err := shlex.Split(expansion) + if err != nil { + return false + } + + cmd, _, err := rootCmd.Traverse(split) + return err == nil && cmd != rootCmd +} diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go new file mode 100644 index 000000000..95f3a5031 --- /dev/null +++ b/pkg/cmd/alias/set/set_test.go @@ -0,0 +1,252 @@ +package set + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCommand(cfg config.Config, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdSet(factory, nil) + + // fake command nesting structure needed for validCommand + rootCmd := &cobra.Command{} + rootCmd.AddCommand(cmd) + prCmd := &cobra.Command{Use: "pr"} + prCmd.AddCommand(&cobra.Command{Use: "checkout"}) + prCmd.AddCommand(&cobra.Command{Use: "status"}) + rootCmd.AddCommand(prCmd) + issueCmd := &cobra.Command{Use: "issue"} + issueCmd.AddCommand(&cobra.Command{Use: "list"}) + rootCmd.AddCommand(issueCmd) + + argv, err := shlex.Split("set " + cli) + if err != nil { + return nil, err + } + rootCmd.SetArgs(argv) + + rootCmd.SetIn(&bytes.Buffer{}) + rootCmd.SetOut(ioutil.Discard) + rootCmd.SetErr(ioutil.Discard) + + _, err = rootCmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestAliasSet_gh_command(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(``) + + _, err := runCommand(cfg, true, "pr 'pr status'") + + if assert.Error(t, err) { + assert.Equal(t, `could not create alias: "pr" is already a gh command`, err.Error()) + } +} + +func TestAliasSet_empty_aliases(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + editor: vim + `)) + + output, err := runCommand(cfg, true, "co 'pr checkout'") + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Added alias") + test.ExpectLines(t, output.String(), "") + + expected := `aliases: + co: pr checkout +editor: vim +` + assert.Equal(t, expected, mainBuf.String()) +} + +func TestAliasSet_existing_alias(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + co: pr checkout + `)) + + output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'") + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo") +} + +func TestAliasSet_space_args(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`) + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`) + + test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`) +} + +func TestAliasSet_arg_processing(t *testing.T) { + cases := []struct { + Cmd string + ExpectedOutputLine string + ExpectedConfigLine string + }{ + {`il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"}, + + {`iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"}, + + {`ii 'issue list --author="$1" --label="$2"'`, + `- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`, + `ii: issue list --author="\$1" --label="\$2"`}, + + {`ix "issue list --author='\$1' --label='\$2'"`, + `- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`, + `ix: issue list --author='\$1' --label='\$2'`}, + } + + for _, c := range cases { + t.Run(c.Cmd, func(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, c.Cmd) + if err != nil { + t.Fatalf("got unexpected error running %s: %s", c.Cmd, err) + } + + test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine) + test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine) + }) + } +} + +func TestAliasSet_init_alias_cfg(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + editor: vim + `)) + + output, err := runCommand(cfg, true, "diff 'pr diff'") + require.NoError(t, err) + + expected := `editor: vim +aliases: + diff: pr diff +` + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.") + assert.Equal(t, expected, mainBuf.String()) +} + +func TestAliasSet_existing_aliases(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + foo: bar + `)) + + output, err := runCommand(cfg, true, "view 'pr view'") + require.NoError(t, err) + + expected := `aliases: + foo: bar + view: pr view +` + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.") + assert.Equal(t, expected, mainBuf.String()) + +} + +func TestAliasSet_invalid_command(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(``) + + _, err := runCommand(cfg, true, "co 'pe checkout'") + if assert.Error(t, err) { + assert.Equal(t, "could not create alias: pe checkout does not correspond to a gh command", err.Error()) + } +} + +func TestShellAlias_flag(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, "--shell igrep 'gh issue list | grep'") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") + + expected := `aliases: + igrep: '!gh issue list | grep' +` + assert.Equal(t, expected, mainBuf.String()) +} + +func TestShellAlias_bang(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'") + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") + + expected := `aliases: + igrep: '!gh issue list | grep' +` + assert.Equal(t, expected, mainBuf.String()) +} diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 8900f3f39..74fc44ea8 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -38,6 +39,7 @@ type ApiOptions struct { HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) } func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command { @@ -45,6 +47,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command IO: f.IOStreams, HttpClient: f.HttpClient, BaseRepo: f.BaseRepo, + Branch: f.Branch, } cmd := &cobra.Command{ @@ -55,8 +58,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command The endpoint argument should either be a path of a GitHub API v3 endpoint, or "graphql" to access the GitHub API v4. -Placeholder values ":owner" and ":repo" in the endpoint argument will get replaced -with values from the repository of the current directory. +Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will +get replaced with values from the repository of the current directory. The default HTTP request method is "GET" normally and "POST" if any parameters were added. Override the method with '--method'. @@ -69,8 +72,8 @@ on the format of the value: - literal values "true", "false", "null", and integer numbers get converted to appropriate JSON types; -- placeholder values ":owner" and ":repo" get populated with values from the - repository of the current directory; +- placeholder values ":owner", ":repo", and ":branch" get populated with values + from the repository of the current directory; - if the value starts with "@", the rest of the value is interpreted as a filename to read the value from. Pass "-" to read from standard input. @@ -193,9 +196,11 @@ func apiRun(opts *ApiOptions) error { opts.IO.Out = ioutil.Discard } + host := ghinstance.OverridableDefault() + hasNextPage := true for hasNextPage { - resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders) + resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) if err != nil { return err } @@ -285,7 +290,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream return } -var placeholderRE = regexp.MustCompile(`\:(owner|repo)\b`) +var placeholderRE = regexp.MustCompile(`\:(owner|repo|branch)\b`) // fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository func fillPlaceholders(value string, opts *ApiOptions) (string, error) { @@ -298,18 +303,28 @@ func fillPlaceholders(value string, opts *ApiOptions) (string, error) { return value, err } - value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string { + filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string { switch m { case ":owner": return baseRepo.RepoOwner() case ":repo": return baseRepo.RepoName() + case ":branch": + branch, e := opts.Branch() + if e != nil { + err = e + } + return branch default: panic(fmt.Sprintf("invalid placeholder: %q", m)) } }) - return value, nil + if err != nil { + return value, err + } + + return filled, nil } func printHeaders(w io.Writer, headers http.Header, colorize bool) { @@ -426,23 +441,43 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) var parsedBody struct { Message string - Errors []struct { - Message string - } + Errors []json.RawMessage } err = json.Unmarshal(b, &parsedBody) if err != nil { return r, "", err } - if parsedBody.Message != "" { return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil - } else if len(parsedBody.Errors) > 0 { - msgs := make([]string, len(parsedBody.Errors)) - for i, e := range parsedBody.Errors { - msgs[i] = e.Message + } + + type errorMessage struct { + Message string + } + var errors []string + for _, rawErr := range parsedBody.Errors { + if len(rawErr) == 0 { + continue } - return bodyCopy, strings.Join(msgs, "\n"), nil + if rawErr[0] == '{' { + var objectError errorMessage + err := json.Unmarshal(rawErr, &objectError) + if err != nil { + return r, "", err + } + errors = append(errors, objectError.Message) + } else if rawErr[0] == '"' { + var stringError string + err := json.Unmarshal(rawErr, &stringError) + if err != nil { + return r, "", err + } + errors = append(errors, stringError) + } + } + + if len(errors) > 0 { + return bodyCopy, strings.Join(errors, "\n"), nil } return bodyCopy, "", nil diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index f590d93b4..a810fa7fc 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -9,6 +9,7 @@ import ( "os" "testing" + "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -264,6 +265,17 @@ func Test_apiRun(t *testing.T) { stdout: `{"message": "THIS IS FINE"}`, stderr: "gh: THIS IS FINE (HTTP 400)\n", }, + { + name: "REST string errors", + httpResponse: &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"errors": ["ALSO", "FINE"]}`)), + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + }, + err: cmdutil.SilentError, + stdout: `{"errors": ["ALSO", "FINE"]}`, + stderr: "gh: ALSO\nFINE\n", + }, { name: "GraphQL error", options: ApiOptions{ @@ -742,6 +754,38 @@ func Test_fillPlaceholders(t *testing.T) { want: "repos/hubot/robot-uprising/releases", wantErr: false, }, + { + name: "has branch placeholder", + args: args{ + value: "repos/cli/cli/branches/:branch/protection/required_status_checks", + opts: &ApiOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("cli", "cli"), nil + }, + Branch: func() (string, error) { + return "trunk", nil + }, + }, + }, + want: "repos/cli/cli/branches/trunk/protection/required_status_checks", + wantErr: false, + }, + { + name: "has branch placeholder and git is in detached head", + args: args{ + value: "repos/:owner/:repo/branches/:branch", + opts: &ApiOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("cli", "cli"), nil + }, + Branch: func() (string, error) { + return "", git.ErrNotOnAnyBranch + }, + }, + }, + want: "repos/:owner/:repo/branches/:branch", + wantErr: true, + }, { name: "no greedy substitutes", args: args{ diff --git a/pkg/cmd/api/http.go b/pkg/cmd/api/http.go index 696ebd61f..974373e69 100644 --- a/pkg/cmd/api/http.go +++ b/pkg/cmd/api/http.go @@ -9,20 +9,23 @@ import ( "net/url" "strconv" "strings" + + "github.com/cli/cli/internal/ghinstance" ) -func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) { +func httpRequest(client *http.Client, hostname string, method string, p string, params interface{}, headers []string) (*http.Response, error) { + isGraphQL := p == "graphql" var requestURL string if strings.Contains(p, "://") { requestURL = p + } else if isGraphQL { + requestURL = ghinstance.GraphQLEndpoint(hostname) } else { - // TODO: GHE support - requestURL = "https://api.github.com/" + p + requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/") } var body io.Reader var bodyIsJSON bool - isGraphQL := p == "graphql" switch pp := params.(type) { case map[string]interface{}: diff --git a/pkg/cmd/api/http_test.go b/pkg/cmd/api/http_test.go index f0768b026..3925fd0be 100644 --- a/pkg/cmd/api/http_test.go +++ b/pkg/cmd/api/http_test.go @@ -93,6 +93,7 @@ func Test_httpRequest(t *testing.T) { type args struct { client *http.Client + host string method string p string params interface{} @@ -114,6 +115,7 @@ func Test_httpRequest(t *testing.T) { name: "simple GET", args: args{ client: &httpClient, + host: "github.com", method: "GET", p: "repos/octocat/spoon-knife", params: nil, @@ -127,10 +129,47 @@ func Test_httpRequest(t *testing.T) { headers: "", }, }, + { + name: "GET with leading slash", + args: args{ + client: &httpClient, + host: "github.com", + method: "GET", + p: "/repos/octocat/spoon-knife", + params: nil, + headers: []string{}, + }, + wantErr: false, + want: expects{ + method: "GET", + u: "https://api.github.com/repos/octocat/spoon-knife", + body: "", + headers: "", + }, + }, + { + name: "Enterprise REST", + args: args{ + client: &httpClient, + host: "example.org", + method: "GET", + p: "repos/octocat/spoon-knife", + params: nil, + headers: []string{}, + }, + wantErr: false, + want: expects{ + method: "GET", + u: "https://example.org/api/v3/repos/octocat/spoon-knife", + body: "", + headers: "", + }, + }, { name: "GET with params", args: args{ client: &httpClient, + host: "github.com", method: "GET", p: "repos/octocat/spoon-knife", params: map[string]interface{}{ @@ -150,6 +189,7 @@ func Test_httpRequest(t *testing.T) { name: "POST with params", args: args{ client: &httpClient, + host: "github.com", method: "POST", p: "repos", params: map[string]interface{}{ @@ -169,6 +209,7 @@ func Test_httpRequest(t *testing.T) { name: "POST GraphQL", args: args{ client: &httpClient, + host: "github.com", method: "POST", p: "graphql", params: map[string]interface{}{ @@ -184,10 +225,29 @@ func Test_httpRequest(t *testing.T) { headers: "Content-Type: application/json; charset=utf-8\r\n", }, }, + { + name: "Enterprise GraphQL", + args: args{ + client: &httpClient, + host: "example.org", + method: "POST", + p: "graphql", + params: map[string]interface{}{}, + headers: []string{}, + }, + wantErr: false, + want: expects{ + method: "POST", + u: "https://example.org/api/graphql", + body: `{}`, + headers: "Content-Type: application/json; charset=utf-8\r\n", + }, + }, { name: "POST with body and type", args: args{ client: &httpClient, + host: "github.com", method: "POST", p: "repos", params: bytes.NewBufferString("CUSTOM"), @@ -207,7 +267,7 @@ func Test_httpRequest(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := httpRequest(tt.args.client, tt.args.method, tt.args.p, tt.args.params, tt.args.headers) + got, err := httpRequest(tt.args.client, tt.args.host, tt.args.method, tt.args.p, tt.args.params, tt.args.headers) if (err != nil) != tt.wantErr { t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go new file mode 100644 index 000000000..67c6abb31 --- /dev/null +++ b/pkg/cmd/auth/auth.go @@ -0,0 +1,25 @@ +package auth + +import ( + authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login" + authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout" + authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh" + authStatusCmd "github.com/cli/cli/pkg/cmd/auth/status" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth ", + Short: "Login, logout, and refresh your authentication", + Long: `Manage gh's authentication state.`, + } + + cmd.AddCommand(authLoginCmd.NewCmdLogin(f, nil)) + cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil)) + cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) + cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) + + return cmd +} diff --git a/pkg/cmd/auth/client/client.go b/pkg/cmd/auth/client/client.go new file mode 100644 index 000000000..bacde0b82 --- /dev/null +++ b/pkg/cmd/auth/client/client.go @@ -0,0 +1,48 @@ +package client + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" +) + +func ValidateHostCfg(hostname string, cfg config.Config) error { + apiClient, err := ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + + err = apiClient.HasMinimumScopes(hostname) + if err != nil { + return fmt.Errorf("could not validate token: %w", err) + } + + return nil +} + +var ClientFromCfg = func(hostname string, cfg config.Config) (*api.Client, error) { + var opts []api.ClientOption + + token, err := cfg.Get(hostname, "oauth_token") + if err != nil { + return nil, err + } + + if token == "" { + return nil, fmt.Errorf("no token found in config for %s", hostname) + } + + opts = append(opts, + // no access to Version so the user agent is more generic here. + api.AddHeader("User-Agent", "GitHub CLI"), + api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { + return fmt.Sprintf("token %s", token), nil + }), + ) + + httpClient := api.NewHTTPClient(opts...) + + return api.NewClientFromHTTP(httpClient), nil +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go new file mode 100644 index 000000000..7d53839c6 --- /dev/null +++ b/pkg/cmd/auth/login/login.go @@ -0,0 +1,290 @@ +package login + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type LoginOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string + Token string + OnlyValidate bool +} + +func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + opts := &LoginOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "login", + Args: cobra.ExactArgs(0), + Short: "Authenticate with a GitHub host", + Long: heredoc.Doc(`Authenticate with a GitHub host. + + This interactive command initializes your authentication state either by helping you log into + GitHub via browser-based OAuth or by accepting a Personal Access Token. + + The interactivity can be avoided by specifying --with-token and passing a token on STDIN. + `), + Example: heredoc.Doc(` + $ gh auth login + # => do an interactive setup + + $ gh auth login --with-token < mytoken.txt + # => read token from mytoken.txt and authenticate against github.com + + $ gh auth login --hostname enterprise.internal --with-token < mytoken.txt + # => read token from mytoken.txt and authenticate against a GitHub Enterprise instance + `), + RunE: func(cmd *cobra.Command, args []string) error { + isTTY := opts.IO.IsStdinTTY() + + // TODO support other ways of naming + ghToken := os.Getenv("GITHUB_TOKEN") + + if !isTTY && (!cmd.Flags().Changed("with-token") && ghToken == "") { + return &cmdutil.FlagError{Err: errors.New("no terminal detected; please use '--with-token' or set GITHUB_TOKEN")} + } + + wt, _ := cmd.Flags().GetBool("with-token") + if wt { + defer opts.IO.In.Close() + token, err := ioutil.ReadAll(opts.IO.In) + if err != nil { + return &cmdutil.FlagError{Err: fmt.Errorf("failed to read token from STDIN: %w", err)} + } + + opts.Token = strings.TrimSpace(string(token)) + } else if ghToken != "" { + opts.OnlyValidate = true + opts.Token = ghToken + } + + if opts.Token != "" { + // Assume non-interactive if a token is specified + if opts.Hostname == "" { + opts.Hostname = ghinstance.Default() + } + } + + if runF != nil { + return runF(opts) + } + + return loginRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with") + cmd.Flags().Bool("with-token", false, "Read token from standard input") + + return cmd +} + +func loginRun(opts *LoginOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + if opts.Token != "" { + // I chose to not error on existing host here; my thinking is that for --with-token the user + // probably doesn't care if a token is overwritten since they have a token in hand they + // explicitly want to use. + if opts.Hostname == "" { + return errors.New("empty hostname would leak oauth_token") + } + + err := cfg.Set(opts.Hostname, "oauth_token", opts.Token) + if err != nil { + return err + } + + err = client.ValidateHostCfg(opts.Hostname, cfg) + if err != nil { + return err + } + + if opts.OnlyValidate { + return nil + } + + return cfg.Write() + } + + // TODO consider explicitly telling survey what io to use since it's implicit right now + + hostname := opts.Hostname + + if hostname == "" { + var hostType int + err := prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to log into?", + Options: []string{ + "GitHub.com", + "GitHub Enterprise", + }, + }, &hostType) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + isEnterprise := hostType == 1 + + hostname = ghinstance.Default() + if isEnterprise { + err := prompt.SurveyAskOne(&survey.Input{ + Message: "GHE hostname:", + }, &hostname, survey.WithValidator(survey.Required)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } + + fmt.Fprintf(opts.IO.ErrOut, "- Logging into %s\n", hostname) + + existingToken, _ := cfg.Get(hostname, "oauth_token") + + if existingToken != "" { + err := client.ValidateHostCfg(hostname, cfg) + if err == nil { + apiClient, err := client.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("error using api: %w", err) + } + var keepGoing bool + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf( + "You're already logged into %s as %s. Do you want to re-authenticate?", + hostname, + username), + Default: false, + }, &keepGoing) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !keepGoing { + return nil + } + } + } + + var authMode int + err = prompt.SurveyAskOne(&survey.Select{ + Message: "How would you like to authenticate?", + Options: []string{ + "Login with a web browser", + "Paste an authentication token", + }, + }, &authMode) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if authMode == 0 { + _, err := config.AuthFlowWithConfig(cfg, hostname, "", []string{}) + if err != nil { + return fmt.Errorf("failed to authenticate via web browser: %w", err) + } + } else { + fmt.Fprintln(opts.IO.ErrOut) + fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(` + Tip: you can generate a Personal Access Token here https://github.com/settings/tokens + The minimum required scopes are 'repo' and 'read:org'.`)) + var token string + err := prompt.SurveyAskOne(&survey.Password{ + Message: "Paste your authentication token:", + }, &token, survey.WithValidator(survey.Required)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if hostname == "" { + return errors.New("empty hostname would leak oauth_token") + } + + err = cfg.Set(hostname, "oauth_token", token) + if err != nil { + return err + } + + err = client.ValidateHostCfg(hostname, cfg) + if err != nil { + return err + } + } + + var gitProtocol string + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Choose default git protocol", + Options: []string{ + "HTTPS", + "SSH", + }, + }, &gitProtocol) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + gitProtocol = strings.ToLower(gitProtocol) + + fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h%s git_protocol %s\n", hostname, gitProtocol) + err = cfg.Set(hostname, "git_protocol", gitProtocol) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) + + apiClient, err := client.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("error using api: %w", err) + } + + err = cfg.Set(hostname, "user", username) + if err != nil { + return err + } + + err = cfg.Write() + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", utils.GreenCheck(), utils.Bold(username)) + + return nil +} diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go new file mode 100644 index 000000000..4af891b66 --- /dev/null +++ b/pkg/cmd/auth/login/login_test.go @@ -0,0 +1,423 @@ +package login + +import ( + "bytes" + "net/http" + "os" + "regexp" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdLogin(t *testing.T) { + tests := []struct { + name string + cli string + stdin string + stdinTTY bool + wants LoginOptions + wantsErr bool + ghtoken string + }{ + { + name: "nontty, with-token", + stdin: "abc123\n", + cli: "--with-token", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + }, + }, + { + name: "tty, with-token", + stdinTTY: true, + stdin: "def456", + cli: "--with-token", + wants: LoginOptions{ + Hostname: "github.com", + Token: "def456", + }, + }, + { + name: "nontty, hostname", + cli: "--hostname claire.redfield", + wantsErr: true, + }, + { + name: "nontty", + cli: "", + wantsErr: true, + }, + { + name: "nontty, with-token, hostname", + cli: "--hostname claire.redfield --with-token", + stdin: "abc123\n", + wants: LoginOptions{ + Hostname: "claire.redfield", + Token: "abc123", + }, + }, + { + name: "tty, with-token, hostname", + stdinTTY: true, + stdin: "ghi789", + cli: "--with-token --hostname brad.vickers", + wants: LoginOptions{ + Hostname: "brad.vickers", + Token: "ghi789", + }, + }, + { + name: "tty, hostname", + stdinTTY: true, + cli: "--hostname barry.burton", + wants: LoginOptions{ + Hostname: "barry.burton", + Token: "", + }, + }, + { + name: "tty", + stdinTTY: true, + cli: "", + wants: LoginOptions{ + Hostname: "", + Token: "", + }, + }, + { + name: "tty, GITHUB_TOKEN", + stdinTTY: true, + cli: "", + ghtoken: "abc123", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + OnlyValidate: true, + }, + }, + { + name: "nontty, GITHUB_TOKEN", + stdinTTY: false, + cli: "", + ghtoken: "abc123", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + OnlyValidate: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + io, stdin, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + io.SetStdinTTY(tt.stdinTTY) + if tt.stdin != "" { + stdin.WriteString(tt.stdin) + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *LoginOptions + cmd := NewCmdLogin(f, func(opts *LoginOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Token, gotOpts.Token) + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + }) + } +} + +func Test_loginRun_nontty(t *testing.T) { + tests := []struct { + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + wantHosts string + wantErr *regexp.Regexp + }{ + { + name: "with token", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc123", + }, + wantHosts: "github.com:\n oauth_token: abc123\n", + }, + { + name: "with token and non-default host", + opts: &LoginOptions{ + Hostname: "albert.wesker", + Token: "abc123", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + }, + wantHosts: "albert.wesker:\n oauth_token: abc123\n", + }, + { + name: "missing repo scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) + }, + wantErr: regexp.MustCompile(`missing required scope 'repo'`), + }, + { + name: "missing read scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo")) + }, + wantErr: regexp.MustCompile(`missing required scope 'read:org'`), + }, + { + name: "has admin scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org")) + }, + wantHosts: "github.com:\n oauth_token: abc456\n", + }, + } + + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + + io.SetStdinTTY(false) + io.SetStdoutTTY(false) + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + tt.opts.IO = io + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + origClientFromCfg := client.ClientFromCfg + defer func() { + client.ClientFromCfg = origClientFromCfg + }() + client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + httpClient := &http.Client{Transport: reg} + return api.NewClientFromHTTP(httpClient), nil + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } else { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + err := loginRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} + +func Test_loginRun_Survey(t *testing.T) { + tests := []struct { + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + wantHosts string + cfg func(config.Config) + }{ + { + name: "already authenticated", + cfg: func(cfg config.Config) { + _ = cfg.Set("github.com", "oauth_token", "ghi789") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(false) // do not continue + }, + wantHosts: "", // nothing should have been written to hosts + }, + { + name: "hostname set", + opts: &LoginOptions{ + Hostname: "rebecca.chambers", + }, + wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + }, + { + name: "choose enterprise", + wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) // host type enterprise + as.StubOne("brad.vickers") // hostname + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + }, + { + name: "choose github.com", + wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + }, + { + name: "sets git_protocol", + wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("SSH") // git_protocol + }, + }, + // TODO how to test browser auth? + } + + for _, tt := range tests { + if tt.opts == nil { + tt.opts = &LoginOptions{} + } + io, _, _, _ := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStderrTTY(true) + io.SetStdoutTTY(true) + + tt.opts.IO = io + + cfg := config.NewBlankConfig() + + if tt.cfg != nil { + tt.cfg(cfg) + } + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + origClientFromCfg := client.ClientFromCfg + defer func() { + client.ClientFromCfg = origClientFromCfg + }() + client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + httpClient := &http.Client{Transport: reg} + return api.NewClientFromHTTP(httpClient), nil + } + if tt.httpStubs != nil { + tt.httpStubs(reg) + } else { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + err := loginRun(tt.opts) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go new file mode 100644 index 000000000..6f047a079 --- /dev/null +++ b/pkg/cmd/auth/logout/logout.go @@ -0,0 +1,162 @@ +package logout + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type LogoutOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string +} + +func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { + opts := &LogoutOptions{ + HttpClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "logout", + Args: cobra.ExactArgs(0), + Short: "Log out of a GitHub host", + Long: heredoc.Doc(`Remove authentication for a GitHub host. + + This command removes the authentication configuration for a host either specified + interactively or via --hostname. + `), + Example: heredoc.Doc(` + $ gh auth logout + # => select what host to log out of via a prompt + + $ gh auth logout --hostname enterprise.internal + # => log out of specified host + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return logoutRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to log out of") + + return cmd +} + +func logoutRun(opts *LogoutOptions) error { + if os.Getenv("GITHUB_TOKEN") != "" { + return errors.New("GITHUB_TOKEN is set in your environment. If you no longer want to use it with gh, please unset it.") + } + + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + + hostname := opts.Hostname + + if !isTTY && hostname == "" { + return errors.New("--hostname required when not attached to a terminal") + } + + showConfirm := isTTY && hostname == "" + + cfg, err := opts.Config() + if err != nil { + return err + } + + candidates, err := cfg.Hosts() + if err != nil { + return fmt.Errorf("not logged in to any hosts") + } + + if hostname == "" { + if len(candidates) == 1 { + hostname = candidates[0] + } else { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to log out of?", + Options: candidates, + }, &hostname) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } else { + var found bool + for _, c := range candidates { + if c == hostname { + found = true + break + } + } + + if !found { + return fmt.Errorf("not logged into %s", hostname) + } + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + // suppressing; the user is trying to delete this token and it might be bad. + // we'll see if the username is in the config and fall back to that. + username, _ = cfg.Get(hostname, "user") + } + + usernameStr := "" + if username != "" { + usernameStr = fmt.Sprintf(" account '%s'", username) + } + + if showConfirm { + var keepGoing bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Are you sure you want to log out of %s%s?", hostname, usernameStr), + Default: true, + }, &keepGoing) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !keepGoing { + return nil + } + } + + cfg.UnsetHost(hostname) + err = cfg.Write() + if err != nil { + return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) + } + + if isTTY { + fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n", + utils.GreenCheck(), utils.Bold(hostname), usernameStr) + } + + return nil +} diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go new file mode 100644 index 000000000..50e1bed2a --- /dev/null +++ b/pkg/cmd/auth/logout/logout_test.go @@ -0,0 +1,272 @@ +package logout + +import ( + "bytes" + "net/http" + "os" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdLogout(t *testing.T) { + tests := []struct { + name string + cli string + wants LogoutOptions + }{ + { + name: "with hostname", + cli: "--hostname harry.mason", + wants: LogoutOptions{ + Hostname: "harry.mason", + }, + }, + { + name: "no arguments", + cli: "", + wants: LogoutOptions{ + Hostname: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *LogoutOptions + cmd := NewCmdLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + }) + + } +} + +func Test_logoutRun_tty(t *testing.T) { + tests := []struct { + name string + opts *LogoutOptions + askStubs func(*prompt.AskStubber) + cfgHosts []string + wantHosts string + wantErrOut *regexp.Regexp + wantErr *regexp.Regexp + }{ + { + name: "no arguments, multiple hosts", + opts: &LogoutOptions{}, + cfgHosts: []string{"cheryl.mason", "github.com"}, + wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("github.com") + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + }, + { + name: "no arguments, one host", + opts: &LogoutOptions{}, + cfgHosts: []string{"github.com"}, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + }, + { + name: "no arguments, no hosts", + opts: &LogoutOptions{}, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + { + name: "hostname", + opts: &LogoutOptions{ + Hostname: "cheryl.mason", + }, + cfgHosts: []string{"cheryl.mason", "github.com"}, + wantHosts: "github.com:\n oauth_token: abc123\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`)) + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + err := logoutRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + if tt.wantErrOut == nil { + assert.Equal(t, "", stderr.String()) + } else { + assert.True(t, tt.wantErrOut.MatchString(stderr.String())) + } + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} + +func Test_logoutRun_nontty(t *testing.T) { + tests := []struct { + name string + opts *LogoutOptions + cfgHosts []string + wantHosts string + wantErr *regexp.Regexp + ghtoken string + }{ + { + name: "no arguments", + wantErr: regexp.MustCompile(`hostname required when not`), + opts: &LogoutOptions{}, + }, + { + name: "hostname, one host", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + cfgHosts: []string{"harry.mason"}, + }, + { + name: "hostname, multiple hosts", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + cfgHosts: []string{"harry.mason", "cheryl.mason"}, + wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + }, + { + name: "hostname, no hosts", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + { + name: "gh token is set", + opts: &LogoutOptions{}, + ghtoken: "abc123", + wantErr: regexp.MustCompile(`GITHUB_TOKEN is set in your environment`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(false) + io.SetStdoutTTY(false) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + + reg := &httpmock.Registry{} + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + err := logoutRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go new file mode 100644 index 000000000..8a69ea0e2 --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh.go @@ -0,0 +1,116 @@ +package refresh + +import ( + "fmt" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/spf13/cobra" +) + +type RefreshOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string + Scopes []string + AuthFlow func(config.Config, string, []string) error +} + +func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { + opts := &RefreshOptions{ + IO: f.IOStreams, + Config: f.Config, + AuthFlow: func(cfg config.Config, hostname string, scopes []string) error { + _, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes) + return err + }, + } + + cmd := &cobra.Command{ + Use: "refresh", + Args: cobra.ExactArgs(0), + Short: "Refresh stored authentication credentials", + Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials + + The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If + absent, this command ensures that gh has access to a minimum set of scopes. + `), + Example: heredoc.Doc(` + $ gh auth refresh --scopes write:org,read:public_key + # => open a browser to add write:org and read:public_key scopes for use with gh api + + $ gh auth refresh + # => open a browser to ensure your authentication credentials have the correct minimum scopes + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return refreshRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication") + cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have") + + return cmd +} + +func refreshRun(opts *RefreshOptions) error { + if os.Getenv("GITHUB_TOKEN") != "" { + return fmt.Errorf("GITHUB_TOKEN is present in your environment and is incompatible with this command. If you'd like to modify a personal access token, see https://github.com/settings/tokens") + } + + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + + if !isTTY { + return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + candidates, err := cfg.Hosts() + if err != nil { + return fmt.Errorf("not logged in to any hosts. Use 'gh auth login' to authenticate with a host") + } + + hostname := opts.Hostname + if hostname == "" { + if len(candidates) == 1 { + hostname = candidates[0] + } else { + err := prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to refresh auth for?", + Options: candidates, + }, &hostname) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } else { + var found bool + for _, c := range candidates { + if c == hostname { + found = true + break + } + } + + if !found { + return fmt.Errorf("not logged in to %s. use 'gh auth login' to authenticate with this host", hostname) + } + } + + return opts.AuthFlow(cfg, hostname, opts.Scopes) +} diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go new file mode 100644 index 000000000..3a22d507f --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -0,0 +1,244 @@ +package refresh + +import ( + "bytes" + "os" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdRefresh(t *testing.T) { + tests := []struct { + name string + cli string + wants RefreshOptions + }{ + { + name: "no arguments", + wants: RefreshOptions{ + Hostname: "", + }, + }, + { + name: "hostname", + cli: "-h aline.cedrac", + wants: RefreshOptions{ + Hostname: "aline.cedrac", + }, + }, + { + name: "one scope", + cli: "--scopes repo:invite", + wants: RefreshOptions{ + Scopes: []string{"repo:invite"}, + }, + }, + { + name: "scopes", + cli: "--scopes repo:invite,read:public_key", + wants: RefreshOptions{ + Scopes: []string{"repo:invite", "read:public_key"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *RefreshOptions + cmd := NewCmdRefresh(f, func(opts *RefreshOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes) + }) + + } +} + +type authArgs struct { + hostname string + scopes []string +} + +func Test_refreshRun(t *testing.T) { + tests := []struct { + name string + opts *RefreshOptions + askStubs func(*prompt.AskStubber) + cfgHosts []string + wantErr *regexp.Regexp + ghtoken string + nontty bool + wantAuthArgs authArgs + }{ + { + name: "GITHUB_TOKEN set", + opts: &RefreshOptions{}, + ghtoken: "abc123", + wantErr: regexp.MustCompile(`GITHUB_TOKEN is present in your environment`), + }, + { + name: "non tty", + opts: &RefreshOptions{}, + nontty: true, + wantErr: regexp.MustCompile(`not attached to a terminal;`), + }, + { + name: "no hosts configured", + opts: &RefreshOptions{}, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + { + name: "hostname given but dne", + cfgHosts: []string{ + "github.com", + "aline.cedrac", + }, + opts: &RefreshOptions{ + Hostname: "obed.morton", + }, + wantErr: regexp.MustCompile(`not logged in to obed.morton`), + }, + { + name: "hostname provided and is configured", + cfgHosts: []string{ + "obed.morton", + "github.com", + }, + opts: &RefreshOptions{ + Hostname: "obed.morton", + }, + wantAuthArgs: authArgs{ + hostname: "obed.morton", + scopes: nil, + }, + }, + { + name: "no hostname, one host configured", + cfgHosts: []string{ + "github.com", + }, + opts: &RefreshOptions{ + Hostname: "", + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: nil, + }, + }, + { + name: "no hostname, multiple hosts configured", + cfgHosts: []string{ + "github.com", + "aline.cedrac", + }, + opts: &RefreshOptions{ + Hostname: "", + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne("github.com") + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: nil, + }, + }, + { + name: "scopes provided", + cfgHosts: []string{ + "github.com", + }, + opts: &RefreshOptions{ + Scopes: []string{"repo:invite", "public_key:read"}, + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: []string{"repo:invite", "public_key:read"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aa := authArgs{} + tt.opts.AuthFlow = func(_ config.Config, hostname string, scopes []string) error { + aa.hostname = hostname + aa.scopes = scopes + return nil + } + + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + io, _, _, _ := iostreams.Test() + + io.SetStdinTTY(!tt.nontty) + io.SetStdoutTTY(!tt.nontty) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`)) + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + err := refreshRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname) + assert.Equal(t, aa.scopes, tt.wantAuthArgs.scopes) + }) + } +} diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go new file mode 100644 index 000000000..09d2f2fde --- /dev/null +++ b/pkg/cmd/auth/status/status.go @@ -0,0 +1,199 @@ +package status + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + Token string + Hostname string +} + +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + HttpClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "status", + Args: cobra.ExactArgs(0), + Short: "View authentication status", + Long: heredoc.Doc(`Verifies and displays information about your authentication state. + + This command will test your authentication state for each GitHub host that gh knows about and + report on any issues. + `), + RunE: func(cmd *cobra.Command, args []string) error { + // TODO support other names + opts.Token = os.Getenv("GITHUB_TOKEN") + + if opts.Token != "" && opts.Hostname == "" { + opts.Hostname = ghinstance.Default() + } + + if runF != nil { + return runF(opts) + } + + return statusRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "Check a specific hostname's auth status") + + return cmd +} + +func statusRun(opts *StatusOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + // TODO check tty + + stderr := opts.IO.ErrOut + + if opts.Token != "" { + hostname := opts.Hostname + err := cfg.Set(opts.Hostname, "oauth_token", opts.Token) + if err != nil { + return err + } + + apiClient, err := client.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + + err = apiClient.HasMinimumScopes(hostname) + if err != nil { + var missingScopes *api.MissingScopesError + if errors.As(err, &missingScopes) { + fmt.Fprintf(stderr, "%s %s: %s\n", utils.Red("X"), hostname, err) + fmt.Fprintln(stderr, + "The token in GITHUB_TOKEN is valid but missing scopes that gh requires to function.") + } else { + fmt.Fprintf(stderr, "%s %s: authentication failed\n", utils.Red("X"), hostname) + fmt.Fprintln(stderr) + fmt.Fprintf(stderr, + "The token in GITHUB_TOKEN is invalid.\n") + } + fmt.Fprintf(stderr, + "Please visit https://%s/settings/tokens and create a new token with 'repo', 'read:org', and 'gist' scopes.\n", hostname) + return cmdutil.SilentError + } else { + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err) + } + fmt.Fprintf(stderr, + "%s token valid for %s as %s\n", utils.GreenCheck(), hostname, utils.Bold(username)) + proto, _ := cfg.Get(hostname, "git_protocol") + if proto != "" { + fmt.Fprintln(stderr) + fmt.Fprintf(stderr, + "Git operations for %s configured to use %s protocol.\n", hostname, utils.Bold(proto)) + } + } + + return nil + } + + statusInfo := map[string][]string{} + + hostnames, err := cfg.Hosts() + if len(hostnames) == 0 || err != nil { + fmt.Fprintf(stderr, + "You are not logged into any GitHub hosts. Run %s to authenticate.\n", utils.Bold("gh auth login")) + return cmdutil.SilentError + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + var failed bool + + for _, hostname := range hostnames { + if opts.Hostname != "" && opts.Hostname != hostname { + continue + } + + statusInfo[hostname] = []string{} + addMsg := func(x string, ys ...interface{}) { + statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...)) + } + + err = apiClient.HasMinimumScopes(hostname) + if err != nil { + var missingScopes *api.MissingScopesError + if errors.As(err, &missingScopes) { + addMsg("%s %s: %s\n", utils.Red("X"), hostname, err) + addMsg("- To enable the missing scopes, please run %s %s\n", + utils.Bold("gh auth refresh -h"), + utils.Bold(hostname)) + } else { + addMsg("%s %s: authentication failed\n", utils.Red("X"), hostname) + addMsg("- The configured token for %s is no longer valid.", utils.Bold(hostname)) + addMsg("- To re-authenticate, please run %s %s", + utils.Bold("gh auth login -h"), utils.Bold(hostname)) + addMsg("- To forget about this host, please run %s %s", + utils.Bold("gh auth logout -h"), utils.Bold(hostname)) + } + failed = true + } else { + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + addMsg("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err) + } + addMsg("%s Logged in to %s as %s", utils.GreenCheck(), hostname, utils.Bold(username)) + proto, _ := cfg.Get(hostname, "git_protocol") + if proto != "" { + addMsg("%s Git operations for %s configured to use %s protocol.", + utils.GreenCheck(), hostname, utils.Bold(proto)) + } + addMsg("") + } + + // NB we could take this opportunity to add or fix the "user" key in the hosts config. I chose + // not to since I wanted this command to be read-only. + } + + for _, hostname := range hostnames { + lines, ok := statusInfo[hostname] + if !ok { + continue + } + fmt.Fprintf(stderr, "%s\n", utils.Bold(hostname)) + for _, line := range lines { + fmt.Fprintf(stderr, " %s\n", line) + } + } + + if failed { + return cmdutil.SilentError + } + + return nil +} diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go new file mode 100644 index 000000000..4ab0d1b28 --- /dev/null +++ b/pkg/cmd/auth/status/status_test.go @@ -0,0 +1,299 @@ +package status + +import ( + "bytes" + "net/http" + "os" + "regexp" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdStatus(t *testing.T) { + tests := []struct { + name string + cli string + wants StatusOptions + ghtoken string + }{ + { + name: "ghtoken set", + cli: "", + wants: StatusOptions{ + Token: "abc123", + Hostname: "github.com", + }, + ghtoken: "abc123", + }, + { + name: "ghtoken set", + cli: "--hostname joel.miller", + wants: StatusOptions{ + Token: "def456", + Hostname: "joel.miller", + }, + ghtoken: "def456", + }, + { + name: "no arguments", + cli: "", + wants: StatusOptions{}, + }, + { + name: "hostname set", + cli: "--hostname ellie.williams", + wants: StatusOptions{ + Hostname: "ellie.williams", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *StatusOptions + cmd := NewCmdStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Token, gotOpts.Token) + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + }) + } +} + +func Test_statusRun(t *testing.T) { + tests := []struct { + name string + opts *StatusOptions + httpStubs func(*httpmock.Registry) + cfg func(config.Config) + wantErr *regexp.Regexp + wantErrOut *regexp.Regexp + }{ + { + name: "token set, bad token", + opts: &StatusOptions{ + Token: "abc123", + Hostname: "github.com", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", ""), + httpmock.StatusStringResponse(400, "no bueno"), + ) + }, + wantErr: regexp.MustCompile(``), + wantErrOut: regexp.MustCompile(`authentication failed`), + }, + { + name: "token set, missing scope", + opts: &StatusOptions{ + Token: "abc123", + Hostname: "github.com", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,")) + }, + wantErr: regexp.MustCompile(``), + wantErrOut: regexp.MustCompile(`missing required scope 'read:org'`), + }, + { + name: "token set, good token", + opts: &StatusOptions{ + Token: "abc123", + Hostname: "github.com", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`token valid for github.com as.*tess`), + }, + { + name: "hostname set", + opts: &StatusOptions{ + Hostname: "joel.miller", + }, + cfg: func(c config.Config) { + _ = c.Set("joel.miller", "oauth_token", "abc123") + _ = c.Set("github.com", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`Logged in to joel.miller as.*tess`), + }, + { + name: "hostname set", + opts: &StatusOptions{ + Hostname: "joel.miller", + }, + cfg: func(c config.Config) { + _ = c.Set("joel.miller", "oauth_token", "abc123") + _ = c.Set("github.com", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`Logged in to joel.miller as.*tess`), + }, + { + name: "missing scope", + opts: &StatusOptions{}, + cfg: func(c config.Config) { + _ = c.Set("joel.miller", "oauth_token", "abc123") + _ = c.Set("github.com", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`joel.miller: missing required.*Logged in to github.com as.*tess`), + wantErr: regexp.MustCompile(``), + }, + { + name: "bad token", + opts: &StatusOptions{}, + cfg: func(c config.Config) { + _ = c.Set("joel.miller", "oauth_token", "abc123") + _ = c.Set("github.com", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`joel.miller: authentication failed.*Logged in to github.com as.*tess`), + wantErr: regexp.MustCompile(``), + }, + { + name: "all good", + opts: &StatusOptions{}, + cfg: func(c config.Config) { + _ = c.Set("joel.miller", "oauth_token", "abc123") + _ = c.Set("github.com", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + }, + wantErrOut: regexp.MustCompile(`(?s)Logged in to github.com as.*tess.*Logged in to joel.miller as.*tess`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.opts == nil { + tt.opts = &StatusOptions{} + } + + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStderrTTY(true) + io.SetStdoutTTY(true) + + tt.opts.IO = io + + cfg := config.NewBlankConfig() + + if tt.cfg != nil { + tt.cfg(cfg) + } + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + reg := &httpmock.Registry{} + origClientFromCfg := client.ClientFromCfg + defer func() { + client.ClientFromCfg = origClientFromCfg + }() + client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + httpClient := &http.Client{Transport: reg} + return api.NewClientFromHTTP(httpClient), nil + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + err := statusRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + if tt.wantErrOut == nil { + assert.Equal(t, "", stderr.String()) + } else { + assert.True(t, tt.wantErrOut.MatchString(stderr.String())) + } + + assert.Equal(t, "", mainBuf.String()) + assert.Equal(t, "", hostsBuf.String()) + + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go new file mode 100644 index 000000000..b92af7a88 --- /dev/null +++ b/pkg/cmd/config/config.go @@ -0,0 +1,98 @@ +package config + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration for gh", + Long: heredoc.Doc(` + Display or change configuration settings for gh. + + Current respected settings: + - git_protocol: "https" or "ssh". Default is "https". + - editor: if unset, defaults to environment variables. + `), + } + + cmd.AddCommand(NewCmdConfigGet(f)) + cmd.AddCommand(NewCmdConfigSet(f)) + + return cmd +} + +func NewCmdConfigGet(f *cmdutil.Factory) *cobra.Command { + var hostname string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Print the value of a given configuration key", + Example: heredoc.Doc(` + $ gh config get git_protocol + https + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + val, err := cfg.Get(hostname, args[0]) + if err != nil { + return err + } + + if val != "" { + fmt.Fprintf(f.IOStreams.Out, "%s\n", val) + } + return nil + }, + } + + cmd.Flags().StringVarP(&hostname, "host", "h", "", "Get per-host setting") + + return cmd +} + +func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command { + var hostname string + + cmd := &cobra.Command{ + Use: "set ", + Short: "Update configuration with a value for the given key", + Example: heredoc.Doc(` + $ gh config set editor vim + $ gh config set editor "code --wait" + `), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + key, value := args[0], args[1] + err = cfg.Set(hostname, key, value) + if err != nil { + return fmt.Errorf("failed to set %q to %q: %w", key, value, err) + } + + err = cfg.Write() + if err != nil { + return fmt.Errorf("failed to write config to disk: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVarP(&hostname, "host", "h", "", "Set per-host setting") + + return cmd +} diff --git a/pkg/cmd/config/config_test.go b/pkg/cmd/config/config_test.go new file mode 100644 index 000000000..dc7906232 --- /dev/null +++ b/pkg/cmd/config/config_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "errors" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type configStub map[string]string + +func genKey(host, key string) string { + if host != "" { + return host + ":" + key + } + return key +} + +func (c configStub) Get(host, key string) (string, error) { + if v, found := c[genKey(host, key)]; found { + return v, nil + } + return "", errors.New("not found") +} + +func (c configStub) Set(host, key, value string) error { + c[genKey(host, key)] = value + return nil +} + +func (c configStub) Aliases() (*config.AliasConfig, error) { + return nil, nil +} + +func (c configStub) Hosts() ([]string, error) { + return nil, nil +} + +func (c configStub) UnsetHost(hostname string) { +} + +func (c configStub) Write() error { + c["_written"] = "true" + return nil +} + +func TestConfigGet(t *testing.T) { + tests := []struct { + name string + config configStub + args []string + stdout string + stderr string + }{ + { + name: "get key", + config: configStub{ + "editor": "ed", + }, + args: []string{"editor"}, + stdout: "ed\n", + stderr: "", + }, + { + name: "get key scoped by host", + config: configStub{ + "editor": "ed", + "github.com:editor": "vim", + }, + args: []string{"editor", "-h", "github.com"}, + stdout: "vim\n", + stderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return tt.config, nil + }, + } + + cmd := NewCmdConfigGet(f) + cmd.Flags().BoolP("help", "x", false, "") + cmd.SetArgs(tt.args) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + assert.Equal(t, tt.stdout, stdout.String()) + assert.Equal(t, tt.stderr, stderr.String()) + assert.Equal(t, "", tt.config["_written"]) + }) + } +} + +func TestConfigSet(t *testing.T) { + tests := []struct { + name string + config configStub + args []string + expectKey string + stdout string + stderr string + }{ + { + name: "set key", + config: configStub{}, + args: []string{"editor", "vim"}, + expectKey: "editor", + stdout: "", + stderr: "", + }, + { + name: "set key scoped by host", + config: configStub{}, + args: []string{"editor", "vim", "-h", "github.com"}, + expectKey: "github.com:editor", + stdout: "", + stderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return tt.config, nil + }, + } + + cmd := NewCmdConfigSet(f) + cmd.Flags().BoolP("help", "x", false, "") + cmd.SetArgs(tt.args) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + assert.Equal(t, tt.stdout, stdout.String()) + assert.Equal(t, tt.stderr, stderr.String()) + assert.Equal(t, "vim", tt.config[tt.expectKey]) + assert.Equal(t, "true", tt.config["_written"]) + }) + } +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go new file mode 100644 index 000000000..6b46f74d0 --- /dev/null +++ b/pkg/cmd/factory/default.go @@ -0,0 +1,67 @@ +package factory + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" +) + +func New(appVersion string) *cmdutil.Factory { + io := iostreams.System() + + var cachedConfig config.Config + var configError error + configFunc := func() (config.Config, error) { + if cachedConfig != nil || configError != nil { + return cachedConfig, configError + } + cachedConfig, configError = config.ParseDefaultConfig() + if errors.Is(configError, os.ErrNotExist) { + cachedConfig = config.NewBlankConfig() + configError = nil + } + return cachedConfig, configError + } + + rr := &remoteResolver{ + readRemotes: git.Remotes, + getConfig: configFunc, + } + remotesFunc := rr.Resolver() + + return &cmdutil.Factory{ + IOStreams: io, + Config: configFunc, + Remotes: remotesFunc, + HttpClient: func() (*http.Client, error) { + cfg, err := configFunc() + if err != nil { + return nil, err + } + + // TODO: avoid setting Accept header for `api` command + return httpClient(io, cfg, appVersion, true), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + remotes, err := remotesFunc() + if err != nil { + return nil, err + } + return remotes[0], nil + }, + Branch: func() (string, error) { + currentBranch, err := git.CurrentBranch() + if err != nil { + return "", fmt.Errorf("could not determine current branch: %w", err) + } + return currentBranch, nil + }, + } +} diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go new file mode 100644 index 000000000..b113100f7 --- /dev/null +++ b/pkg/cmd/factory/http.go @@ -0,0 +1,68 @@ +package factory + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/iostreams" +) + +// generic authenticated HTTP client for commands +func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { + var opts []api.ClientOption + if verbose := os.Getenv("DEBUG"); verbose != "" { + logTraffic := strings.Contains(verbose, "api") + opts = append(opts, api.VerboseLog(io.ErrOut, logTraffic, io.IsStderrTTY())) + } + + opts = append(opts, + api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)), + api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return fmt.Sprintf("token %s", token), nil + } + + hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) + token, err := cfg.Get(hostname, "oauth_token") + if token == "" { + var notFound *config.NotFoundError + // TODO: check if stdout is TTY too + if errors.As(err, ¬Found) && io.IsStdinTTY() { + // interactive OAuth flow + token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required", nil) + } + if err != nil { + return "", err + } + if token == "" { + // TODO: instruct user how to manually authenticate + return "", fmt.Errorf("authentication required for %s", hostname) + } + } + + return fmt.Sprintf("token %s", token), nil + }), + ) + + if setAccept { + opts = append(opts, + api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) { + // antiope-preview: Checks + accept := "application/vnd.github.antiope-preview+json" + if ghinstance.IsEnterprise(req.URL.Hostname()) { + // shadow-cat-preview: Draft pull requests + accept += ", application/vnd.github.shadow-cat-preview" + } + return accept, nil + }), + ) + } + + return api.NewHTTPClient(opts...) +} diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go new file mode 100644 index 000000000..0df3e0ca8 --- /dev/null +++ b/pkg/cmd/factory/remote_resolver.go @@ -0,0 +1,80 @@ +package factory + +import ( + "errors" + "net/url" + "sort" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" +) + +type remoteResolver struct { + readRemotes func() (git.RemoteSet, error) + getConfig func() (config.Config, error) + urlTranslator func(*url.URL) *url.URL +} + +func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { + var cachedRemotes context.Remotes + var remotesError error + + return func() (context.Remotes, error) { + if cachedRemotes != nil || remotesError != nil { + return cachedRemotes, remotesError + } + + gitRemotes, err := rr.readRemotes() + if err != nil { + remotesError = err + return nil, err + } + if len(gitRemotes) == 0 { + remotesError = errors.New("no git remotes found") + return nil, remotesError + } + + sshTranslate := rr.urlTranslator + if sshTranslate == nil { + sshTranslate = git.ParseSSHConfig().Translator() + } + resolvedRemotes := context.TranslateRemotes(gitRemotes, sshTranslate) + + cfg, err := rr.getConfig() + if err != nil { + return nil, err + } + + knownHosts := map[string]bool{} + knownHosts[ghinstance.Default()] = true + if authenticatedHosts, err := cfg.Hosts(); err == nil { + for _, h := range authenticatedHosts { + knownHosts[h] = true + } + } + + // filter remotes to only those sharing a single, known hostname + var hostname string + cachedRemotes = context.Remotes{} + sort.Sort(resolvedRemotes) + for _, r := range resolvedRemotes { + if hostname == "" { + if !knownHosts[r.RepoHost()] { + continue + } + hostname = r.RepoHost() + } else if r.RepoHost() != hostname { + continue + } + cachedRemotes = append(cachedRemotes, r) + } + + if len(cachedRemotes) == 0 { + remotesError = errors.New("none of the git remotes point to a known GitHub host") + return nil, remotesError + } + return cachedRemotes, nil + } +} diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go new file mode 100644 index 000000000..d0337499c --- /dev/null +++ b/pkg/cmd/factory/remote_resolver_test.go @@ -0,0 +1,42 @@ +package factory + +import ( + "net/url" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_remoteResolver(t *testing.T) { + rr := &remoteResolver{ + readRemotes: func() (git.RemoteSet, error) { + return git.RemoteSet{ + git.NewRemote("fork", "https://example.org/ghe-owner/ghe-fork.git"), + git.NewRemote("origin", "https://github.com/owner/repo.git"), + git.NewRemote("upstream", "https://example.org/ghe-owner/ghe-repo.git"), + }, nil + }, + getConfig: func() (config.Config, error) { + return config.NewFromString(heredoc.Doc(` + hosts: + example.org: + oauth_token: GHETOKEN + `)), nil + }, + urlTranslator: func(u *url.URL) *url.URL { + return u + }, + } + + resolver := rr.Resolver() + remotes, err := resolver() + require.NoError(t, err) + require.Equal(t, 2, len(remotes)) + + assert.Equal(t, "upstream", remotes[0].Name) + assert.Equal(t, "fork", remotes[1].Name) +} diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 10cdd42ea..e2d6975b8 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -7,6 +7,8 @@ import ( "io/ioutil" "net/http" "path" + "regexp" + "sort" "strings" "github.com/MakeNowJust/heredoc" @@ -96,16 +98,28 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("failed to collect files for posting: %w", err) } + gistName := guessGistName(files) + + processMessage := "Creating gist..." + completionMessage := "Created gist" + if gistName != "" { + if len(files) > 1 { + processMessage = "Creating gist with multiple files" + } else { + processMessage = fmt.Sprintf("Creating gist %s", gistName) + } + completionMessage = fmt.Sprintf("Created gist %s", gistName) + } + errOut := opts.IO.ErrOut - fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-")) + fmt.Fprintf(errOut, "%s %s\n", utils.Gray("-"), processMessage) httpClient, err := opts.HttpClient() if err != nil { return err } - // TODO: GHE support - gist, err := apiCreate(httpClient, ghinstance.Default(), opts.Description, opts.Public, files) + gist, err := apiCreate(httpClient, ghinstance.OverridableDefault(), opts.Description, opts.Public, files) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) { @@ -116,7 +130,7 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err) } - fmt.Fprintf(errOut, "%s Created gist\n", utils.Green("✓")) + fmt.Fprintf(errOut, "%s %s\n", utils.Green("✓"), completionMessage) fmt.Fprintln(opts.IO.Out, gist.HTMLURL) @@ -154,3 +168,22 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e return fs, nil } + +func guessGistName(files map[string]string) string { + filenames := make([]string, 0, len(files)) + gistName := "" + + re := regexp.MustCompile(`^gistfile\d+\.txt$`) + for k := range files { + if !re.MatchString(k) { + filenames = append(filenames, k) + } + } + + if len(filenames) > 0 { + sort.Strings(filenames) + gistName = filenames[0] + } + + return gistName +} diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index 686e543ed..bd57cfa2e 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -30,6 +30,24 @@ func Test_processFiles(t *testing.T) { assert.Equal(t, "hey cool how is it going", files["gistfile0.txt"]) } +func Test_guessGistName_stdin(t *testing.T) { + files := map[string]string{"gistfile0.txt": "sample content"} + + gistName := guessGistName(files) + assert.Equal(t, "", gistName) +} + +func Test_guessGistName_userFiles(t *testing.T) { + files := map[string]string{ + "fig.txt": "I am a fig.", + "apple.txt": "I am an apple.", + "gistfile0.txt": "sample content", + } + + gistName := guessGistName(files) + assert.Equal(t, "apple.txt", gistName) +} + func TestNewCmdCreate(t *testing.T) { tests := []struct { name string @@ -157,7 +175,7 @@ func Test_createRun(t *testing.T) { Filenames: []string{fixtureFile}, }, wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n", - wantStderr: "- Creating gist...\n✓ Created gist\n", + wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n", wantErr: false, wantParams: map[string]interface{}{ "public": true, @@ -175,7 +193,7 @@ func Test_createRun(t *testing.T) { Filenames: []string{fixtureFile}, }, wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n", - wantStderr: "- Creating gist...\n✓ Created gist\n", + wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n", wantErr: false, wantParams: map[string]interface{}{ "description": "an incredibly interesting gist", @@ -193,7 +211,7 @@ func Test_createRun(t *testing.T) { }, stdin: "cool stdin content", wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n", - wantStderr: "- Creating gist...\n✓ Created gist\n", + wantStderr: "- Creating gist with multiple files\n✓ Created gist fixture.txt\n", wantErr: false, wantParams: map[string]interface{}{ "files": map[string]interface{}{ diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go new file mode 100644 index 000000000..ea10a8c40 --- /dev/null +++ b/pkg/cmd/gist/gist.go @@ -0,0 +1,19 @@ +package gist + +import ( + gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdGist(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "gist", + Short: "Create gists", + Long: `Work with GitHub gists.`, + } + + cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) + + return cmd +} diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go new file mode 100644 index 000000000..66314a86d --- /dev/null +++ b/pkg/cmd/issue/close/close.go @@ -0,0 +1,80 @@ +package close + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CloseOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { + opts := &CloseOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "close { | }", + Short: "Close issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return closeRun(opts) + }, + } + + return cmd +} + +func closeRun(opts *CloseOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + if issue.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title) + return nil + } + + err = api.IssueClose(apiClient, baseRepo, *issue) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title) + + return nil +} diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go new file mode 100644 index 000000000..eadf7f440 --- /dev/null +++ b/pkg/cmd/issue/close/close_test.go @@ -0,0 +1,119 @@ +package close + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdClose(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueClose(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueClose_alreadyClosed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue", "closed": true} + } } } + `)) + + output, err := runCommand(http, true, "13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueClose_issuesDisabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "13") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go new file mode 100644 index 000000000..8d47e4e27 --- /dev/null +++ b/pkg/cmd/issue/create/create.go @@ -0,0 +1,228 @@ +package create + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/githubtemplate" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RepoOverride string + WebMode bool + + Title string + TitleProvided bool + Body string + BodyProvided bool + + Assignees []string + Labels []string + Projects []string + Milestone string +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new issue", + Example: heredoc.Doc(` + $ gh issue create --title "I found a bug" --body "Nothing works" + $ gh issue create --label "bug,help wanted" + $ gh issue create --label bug --label "help wanted" + $ gh issue create --assignee monalisa,hubot + $ gh issue create --project "Roadmap" + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.TitleProvided = cmd.Flags().Changed("title") + opts.BodyProvided = cmd.Flags().Changed("body") + opts.RepoOverride, _ = cmd.Flags().GetString("repo") + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue") + cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") + cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") + cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") + + return cmd +} + +func createRun(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var nonLegacyTemplateFiles []string + if opts.RepoOverride == "" { + if rootDir, err := git.ToplevelDir(); err == nil { + // TODO: figure out how to stub this in tests + nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE") + } + } + + isTerminal := opts.IO.IsStdoutTTY() + + var milestones []string + if opts.Milestone != "" { + milestones = []string{opts.Milestone} + } + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") + if opts.Title != "" || opts.Body != "" { + openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones) + if err != nil { + return err + } + } else if len(nonLegacyTemplateFiles) > 1 { + openURL += "/choose" + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) + } + + repo, err := api.GitHubRepo(apiClient, baseRepo) + if err != nil { + return err + } + if !repo.HasIssuesEnabled { + return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + } + + action := prShared.SubmitAction + tb := prShared.IssueMetadataState{ + Type: prShared.IssueMetadata, + Assignees: opts.Assignees, + Labels: opts.Labels, + Projects: opts.Projects, + Milestones: milestones, + } + + title := opts.Title + body := opts.Body + + interactive := !(opts.TitleProvided && opts.BodyProvided) + + if interactive && !isTerminal { + return fmt.Errorf("must provide --title and --body when not attached to a terminal") + } + + if interactive { + var legacyTemplateFile *string + if opts.RepoOverride == "" { + if rootDir, err := git.ToplevelDir(); err == nil { + // TODO: figure out how to stub this in tests + legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE") + } + } + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) + if err != nil { + return fmt.Errorf("could not collect title and/or body: %w", err) + } + + action = tb.Action + + if tb.Action == prShared.CancelAction { + fmt.Fprintln(opts.IO.ErrOut, "Discarding.") + + return nil + } + + if title == "" { + title = tb.Title + } + if body == "" { + body = tb.Body + } + } else { + if title == "" { + return fmt.Errorf("title can't be blank") + } + } + + if action == prShared.PreviewAction { + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") + openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones) + if err != nil { + return err + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } else if action == prShared.SubmitAction { + params := map[string]interface{}{ + "title": title, + "body": body, + } + + err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) + if err != nil { + return err + } + + newIssue, err := api.IssueCreate(apiClient, repo, params) + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, newIssue.URL) + } else { + panic("Unreachable state") + } + + return nil +} diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go new file mode 100644 index 000000000..b483d090b --- /dev/null +++ b/pkg/cmd/issue/create/create_test.go @@ -0,0 +1,280 @@ +package create + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdCreate(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueCreate_nontty_error(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + + _, err := runCommand(http, false, `-t hello`) + if err == nil { + t.Fatal("expected error running command `issue create`") + } + + assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error()) + +} + +func TestIssueCreate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `)) + + output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "hello") + eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + +func TestIssueCreate_metadata(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "MonaLisa", "id": "MONAID" }, + "repository": { + "l000": { "name": "bug", "id": "BUGID" }, + "l001": { "name": "TODO", "id": "TODOID" } + } + } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "Cleanup", "id": "CLEANUPID" }, + { "name": "Roadmap", "id": "ROADMAPID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": null }, + "errors": [{ + "type": "NOT_FOUND", + "path": [ "organization" ], + "message": "Could not resolve to an Organization with the login of 'OWNER'." + }] + } + `)) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["title"], "TITLE") + eq(t, inputs["body"], "BODY") + eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + eq(t, inputs["milestoneId"], "BIGONEID") + if v, ok := inputs["userIds"]; ok { + t.Errorf("did not expect userIds: %v", v) + } + if v, ok := inputs["teamIds"]; ok { + t.Errorf("did not expect teamIds: %v", v) + } + })) + + output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + +func TestIssueCreate_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, `-t heres -b johnny`) + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue create`: %v", err) + } +} + +func TestIssueCreate_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, `--web`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/new") + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") +} + +func TestIssueCreate_webTitleBody(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, `-w -t mytitle -b mybody`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") + eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go new file mode 100644 index 000000000..6f2cea6d9 --- /dev/null +++ b/pkg/cmd/issue/issue.go @@ -0,0 +1,45 @@ +package issue + +import ( + "github.com/MakeNowJust/heredoc" + cmdClose "github.com/cli/cli/pkg/cmd/issue/close" + cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" + cmdList "github.com/cli/cli/pkg/cmd/issue/list" + cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" + cmdStatus "github.com/cli/cli/pkg/cmd/issue/status" + cmdView "github.com/cli/cli/pkg/cmd/issue/view" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "issue ", + Short: "Manage issues", + Long: `Work with GitHub issues`, + Example: heredoc.Doc(` + $ gh issue list + $ gh issue create --label bug + $ gh issue view --web + `), + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + An issue can be supplied as argument in any of the following formats: + - by number, e.g. "123"; or + - by URL, e.g. "https://github.com/OWNER/REPO/issues/123". + `), + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdClose.NewCmdClose(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) + cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + return cmd +} diff --git a/test/fixtures/issueList.json b/pkg/cmd/issue/list/fixtures/issueList.json similarity index 100% rename from test/fixtures/issueList.json rename to pkg/cmd/issue/list/fixtures/issueList.json diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go new file mode 100644 index 000000000..d599da975 --- /dev/null +++ b/pkg/cmd/issue/list/list.go @@ -0,0 +1,127 @@ +package list + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + WebMode bool + + Assignee string + Labels []string + State string + LimitResults int + Author string + Mention string + Milestone string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List and filter issues in this repository", + Example: heredoc.Doc(` + $ gh issue list -l "help wanted" + $ gh issue list -A monalisa + $ gh issue list --web + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if opts.LimitResults < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.LimitResults)} + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the issue(s)") + cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels") + cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|all}") + cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of issues to fetch") + cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") + cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention") + cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone `name`") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + isTerminal := opts.IO.IsStdoutTTY() + + if opts.WebMode { + issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") + openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{ + Entity: "issue", + State: opts.State, + Assignee: opts.Assignee, + Labels: opts.Labels, + Author: opts.Author, + Mention: opts.Mention, + Milestone: opts.Milestone, + }) + if err != nil { + return err + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, opts.Assignee, opts.LimitResults, opts.Author, opts.Mention, opts.Milestone) + if err != nil { + return err + } + + if isTerminal { + hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != "" + title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + } + + issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues) + + return nil +} diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go new file mode 100644 index 000000000..7a42eb286 --- /dev/null +++ b/pkg/cmd/issue/list/list_test.go @@ -0,0 +1,223 @@ +package list + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdList(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} +func TestIssueList_nontty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.FileResponse("./fixtures/issueList.json")) + + output, err := runCommand(http, false, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.Stderr(), "") + test.ExpectLines(t, output.String(), + `1[\t]+number won[\t]+label[\t]+\d+`, + `2[\t]+number too[\t]+label[\t]+\d+`, + `4[\t]+number fore[\t]+label[\t]+\d+`) +} + +func TestIssueList_tty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.FileResponse("./fixtures/issueList.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.Stderr(), ` +Showing 3 of 3 open issues in OWNER/REPO + +`) + + test.ExpectLines(t, output.String(), + "number won", + "number too", + "number fore") +} + +func TestIssueList_tty_withFlags(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.GraphQLQuery(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } }`, func(_ string, params map[string]interface{}) { + assert.Equal(t, "probablyCher", params["assignee"].(string)) + assert.Equal(t, "foo", params["author"].(string)) + assert.Equal(t, "me", params["mention"].(string)) + assert.Equal(t, "1.x", params["milestone"].(string)) + assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{})) + assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{})) + })) + + output, err := runCommand(http, true, "-a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), ` +No issues match your search in OWNER/REPO + +`) +} + +func TestIssueList_withInvalidLimitFlag(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + _, err := runCommand(http, true, "--limit=0") + + if err == nil || err.Error() != "invalid limit: 0" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestIssueList_nullAssigneeLabels(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } } + `)) + + _, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + reqBody := struct { + Variables map[string]interface{} + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + _, assigneeDeclared := reqBody.Variables["assignee"] + _, labelsDeclared := reqBody.Variables["labels"] + eq(t, assigneeDeclared, false) + eq(t, labelsDeclared, false) +} + +func TestIssueList_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestIssueList_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "--web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1") + if err != nil { + t.Errorf("error running command `issue list` with `--web` flag: %v", err) + } + + expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, expectedURL) +} diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go new file mode 100644 index 000000000..72a052503 --- /dev/null +++ b/pkg/cmd/issue/reopen/reopen.go @@ -0,0 +1,80 @@ +package reopen + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReopenOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command { + opts := &ReopenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "reopen { | }", + Short: "Reopen issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return reopenRun(opts) + }, + } + + return cmd +} + +func reopenRun(opts *ReopenOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + if !issue.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title) + return nil + } + + err = api.IssueReopen(apiClient, baseRepo, *issue) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title) + + return nil +} diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go new file mode 100644 index 000000000..df8e8ecd7 --- /dev/null +++ b/pkg/cmd/issue/reopen/reopen_test.go @@ -0,0 +1,119 @@ +package reopen + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdReopen(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueReopen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": true, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_alreadyOpen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": false, "title": "The title of the issue"} + } } } + `)) + + output, err := runCommand(http, true, "2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_issuesDisabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "2") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go new file mode 100644 index 000000000..983052f19 --- /dev/null +++ b/pkg/cmd/issue/shared/display.go @@ -0,0 +1,65 @@ +package shared + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/cli/cli/api" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" +) + +func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues []api.Issue) { + table := utils.NewTablePrinter(io) + for _, issue := range issues { + issueNum := strconv.Itoa(issue.Number) + if table.IsTTY() { + issueNum = "#" + issueNum + } + issueNum = prefix + issueNum + labels := IssueLabelList(issue) + if labels != "" && table.IsTTY() { + labels = fmt.Sprintf("(%s)", labels) + } + now := time.Now() + ago := now.Sub(issue.UpdatedAt) + table.AddField(issueNum, nil, prShared.ColorFuncForState(issue.State)) + if !table.IsTTY() { + table.AddField(issue.State, nil, nil) + } + table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil) + table.AddField(labels, nil, utils.Gray) + if table.IsTTY() { + table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray) + } else { + table.AddField(issue.UpdatedAt.String(), nil, nil) + } + table.EndRow() + } + _ = table.Render() + remaining := totalCount - len(issues) + if remaining > 0 { + fmt.Fprintf(io.Out, utils.Gray("%sAnd %d more\n"), prefix, remaining) + } +} + +func IssueLabelList(issue api.Issue) string { + if len(issue.Labels.Nodes) == 0 { + return "" + } + + labelNames := make([]string, 0, len(issue.Labels.Nodes)) + for _, label := range issue.Labels.Nodes { + labelNames = append(labelNames, label.Name) + } + + list := strings.Join(labelNames, ", ") + if issue.Labels.TotalCount > len(issue.Labels.Nodes) { + list += ", …" + } + return list +} diff --git a/command/issue_lookup.go b/pkg/cmd/issue/shared/lookup.go similarity index 83% rename from command/issue_lookup.go rename to pkg/cmd/issue/shared/lookup.go index aa8e0b12c..90a729599 100644 --- a/command/issue_lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -1,4 +1,4 @@ -package command +package shared import ( "fmt" @@ -8,12 +8,10 @@ import ( "strings" "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/ghrepo" - "github.com/spf13/cobra" ) -func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, arg string) (*api.Issue, ghrepo.Interface, error) { +func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { issue, baseRepo, err := issueFromURL(apiClient, arg) if err != nil { return nil, nil, err @@ -22,7 +20,7 @@ func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command return issue, baseRepo, nil } - baseRepo, err = determineBaseRepo(apiClient, cmd, ctx) + baseRepo, err = baseRepoFn() if err != nil { return nil, nil, fmt.Errorf("could not determine base repo: %w", err) } diff --git a/test/fixtures/issueStatus.json b/pkg/cmd/issue/status/fixtures/issueStatus.json similarity index 100% rename from test/fixtures/issueStatus.json rename to pkg/cmd/issue/status/fixtures/issueStatus.json diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go new file mode 100644 index 000000000..d1b68bc0d --- /dev/null +++ b/pkg/cmd/issue/status/status.go @@ -0,0 +1,103 @@ +package status + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) +} + +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show status of relevant issues", + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + return statusRun(opts) + }, + } + + return cmd +} + +func statusRun(opts *StatusOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost()) + if err != nil { + return err + } + + issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) + if err != nil { + return err + } + + out := opts.IO.Out + + fmt.Fprintln(out, "") + fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo)) + fmt.Fprintln(out, "") + + prShared.PrintHeader(out, "Issues assigned to you") + if issuePayload.Assigned.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues) + } else { + message := " There are no issues assigned to you" + prShared.PrintMessage(out, message) + } + fmt.Fprintln(out) + + prShared.PrintHeader(out, "Issues mentioning you") + if issuePayload.Mentioned.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues) + } else { + prShared.PrintMessage(out, " There are no issues mentioning you") + } + fmt.Fprintln(out) + + prShared.PrintHeader(out, "Issues opened by you") + if issuePayload.Authored.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues) + } else { + prShared.PrintMessage(out, " There are no issues opened by you") + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/cmd/issue/status/status_test.go b/pkg/cmd/issue/status/status_test.go new file mode 100644 index 000000000..6087abe1a --- /dev/null +++ b/pkg/cmd/issue/status/status_test.go @@ -0,0 +1,146 @@ +package status + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdStatus(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueStatus(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.FileResponse("./fixtures/issueStatus.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue status`: %v", err) + } + + expectedIssues := []*regexp.Regexp{ + regexp.MustCompile(`(?m)8.*carrots.*about.*ago`), + regexp.MustCompile(`(?m)9.*squash.*about.*ago`), + regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`), + regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`), + } + + for _, r := range expectedIssues { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestIssueStatus_blankSlate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "assigned": { "nodes": [] }, + "mentioned": { "nodes": [] }, + "authored": { "nodes": [] } + } } }`)) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue status`: %v", err) + } + + expectedOutput := ` +Relevant issues in OWNER/REPO + +Issues assigned to you + There are no issues assigned to you + +Issues mentioning you + There are no issues mentioning you + +Issues opened by you + There are no issues opened by you + +` + if output.String() != expectedOutput { + t.Errorf("expected %q, got %q", expectedOutput, output) + } +} + +func TestIssueStatus_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`)) + + _, err := runCommand(http, true, "") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue status`: %v", err) + } +} diff --git a/test/fixtures/issueView_preview.json b/pkg/cmd/issue/view/fixtures/issueView_preview.json similarity index 100% rename from test/fixtures/issueView_preview.json rename to pkg/cmd/issue/view/fixtures/issueView_preview.json diff --git a/test/fixtures/issueView_previewClosedState.json b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json similarity index 100% rename from test/fixtures/issueView_previewClosedState.json rename to pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json diff --git a/test/fixtures/issueView_previewWithEmptyBody.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json similarity index 100% rename from test/fixtures/issueView_previewWithEmptyBody.json rename to pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json diff --git a/test/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json similarity index 100% rename from test/fixtures/issueView_previewWithMetadata.json rename to pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go new file mode 100644 index 000000000..6d2b0eeaa --- /dev/null +++ b/pkg/cmd/issue/view/view.go @@ -0,0 +1,207 @@ +package view + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string + WebMode bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View an issue", + Long: heredoc.Doc(` + Display the title, body, and other information about an issue. + + With '--web', open the issue in a web browser instead. + `), + Example: heredoc.Doc(` + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + openURL := issue.URL + + if opts.WebMode { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + return utils.OpenInBrowser(openURL) + } + if opts.IO.IsStdoutTTY() { + return printHumanIssuePreview(opts.IO.Out, issue) + } + + return printRawIssuePreview(opts.IO.Out, issue) +} + +func printRawIssuePreview(out io.Writer, issue *api.Issue) error { + assignees := issueAssigneeList(*issue) + labels := shared.IssueLabelList(*issue) + projects := issueProjectList(*issue) + + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(out, "title:\t%s\n", issue.Title) + fmt.Fprintf(out, "state:\t%s\n", issue.State) + fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labels) + fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) + fmt.Fprintf(out, "assignees:\t%s\n", assignees) + fmt.Fprintf(out, "projects:\t%s\n", projects) + fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title) + + fmt.Fprintln(out, "--") + fmt.Fprintln(out, issue.Body) + return nil +} + +func printHumanIssuePreview(out io.Writer, issue *api.Issue) error { + now := time.Now() + ago := now.Sub(issue.CreatedAt) + + // Header (Title and State) + fmt.Fprintln(out, utils.Bold(issue.Title)) + fmt.Fprint(out, issueStateTitleWithColor(issue.State)) + fmt.Fprintln(out, utils.Gray(fmt.Sprintf( + " • %s opened %s • %s", + issue.Author.Login, + utils.FuzzyAgo(ago), + utils.Pluralize(issue.Comments.TotalCount, "comment"), + ))) + + // Metadata + fmt.Fprintln(out) + if assignees := issueAssigneeList(*issue); assignees != "" { + fmt.Fprint(out, utils.Bold("Assignees: ")) + fmt.Fprintln(out, assignees) + } + if labels := shared.IssueLabelList(*issue); labels != "" { + fmt.Fprint(out, utils.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + if projects := issueProjectList(*issue); projects != "" { + fmt.Fprint(out, utils.Bold("Projects: ")) + fmt.Fprintln(out, projects) + } + if issue.Milestone.Title != "" { + fmt.Fprint(out, utils.Bold("Milestone: ")) + fmt.Fprintln(out, issue.Milestone.Title) + } + + // Body + if issue.Body != "" { + fmt.Fprintln(out) + md, err := utils.RenderMarkdown(issue.Body) + if err != nil { + return err + } + fmt.Fprintln(out, md) + } + fmt.Fprintln(out) + + // Footer + fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) + return nil +} + +func issueStateTitleWithColor(state string) string { + colorFunc := prShared.ColorFuncForState(state) + return colorFunc(strings.Title(strings.ToLower(state))) +} + +func issueAssigneeList(issue api.Issue) string { + if len(issue.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) + for _, assignee := range issue.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { + list += ", …" + } + return list +} + +func issueProjectList(issue api.Issue) string { + if len(issue.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) + for _, project := range issue.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { + list += ", …" + } + return list +} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go new file mode 100644 index 000000000..e27505f92 --- /dev/null +++ b/pkg/cmd/issue/view/view_test.go @@ -0,0 +1,340 @@ +package view + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdView(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueView_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w 123") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_web_numberArgWithHash(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w \"#123\"") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_nontty_Preview(t *testing.T) { + tests := map[string]struct { + fixture string + expectedOutputs []string + }{ + "Open issue without metadata": { + fixture: "./fixtures/issueView_preview.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tOPEN`, + `comments:\t9`, + `author:\tmarseilles`, + `assignees:`, + `\*\*bold story\*\*`, + }, + }, + "Open issue with metadata": { + fixture: "./fixtures/issueView_previewWithMetadata.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `assignees:\tmarseilles, monaco`, + `author:\tmarseilles`, + `state:\tOPEN`, + `comments:\t9`, + `labels:\tone, two, three, four, five`, + `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `milestone:\tuluru\n`, + `\*\*bold story\*\*`, + }, + }, + "Open issue with empty body": { + fixture: "./fixtures/issueView_previewWithEmptyBody.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tOPEN`, + `author:\tmarseilles`, + `labels:\ttarot`, + }, + }, + "Closed issue": { + fixture: "./fixtures/issueView_previewClosedState.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tCLOSED`, + `\*\*bold story\*\*`, + `author:\tmarseilles`, + `labels:\ttarot`, + `\*\*bold story\*\*`, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, false, "123") + if err != nil { + t.Errorf("error running `issue view`: %v", err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_tty_Preview(t *testing.T) { + tests := map[string]struct { + fixture string + expectedOutputs []string + }{ + "Open issue without metadata": { + fixture: "./fixtures/issueView_preview.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Open issue with metadata": { + fixture: "./fixtures/issueView_previewWithMetadata.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `Milestone:.*uluru\n`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Open issue with empty body": { + fixture: "./fixtures/issueView_previewWithEmptyBody.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Closed issue": { + fixture: "./fixtures/issueView_previewClosedState.json", + expectedOutputs: []string{ + `ix of coins`, + `Closed.*marseilles opened about 292 years ago.*9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, true, "123") + if err != nil { + t.Errorf("error running `issue view`: %v", err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_web_notFound(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "errors": [ + { "message": "Could not resolve to an Issue with the number of 9999." } + ] } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + _, err := runCommand(http, true, "-w 9999") + if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." { + t.Errorf("error running command `issue view`: %v", err) + } + + if seenCmd != nil { + t.Fatal("did not expect any command to run") + } +} + +func TestIssueView_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, `6666`) + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue view`: %v", err) + } +} + +func TestIssueView_web_urlArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w https://github.com/OWNER/REPO/issues/123") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 20eef580c..23c4bb70b 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -36,7 +36,6 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, - BaseRepo: f.BaseRepo, Remotes: f.Remotes, Branch: f.Branch, } @@ -51,6 +50,9 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr return nil }, RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + if len(args) > 0 { opts.SelectorArg = args[0] } diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go new file mode 100644 index 000000000..a7d175624 --- /dev/null +++ b/pkg/cmd/pr/close/close.go @@ -0,0 +1,83 @@ +package close + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CloseOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { + opts := &CloseOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "close { | | }", + Short: "Close a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return closeRun(opts) + }, + } + + return cmd +} + +func closeRun(opts *CloseOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + if err != nil { + return err + } + + if pr.State == "MERGED" { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", utils.Red("!"), pr.Number, pr.Title) + return cmdutil.SilentError + } else if pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", utils.Yellow("!"), pr.Number, pr.Title) + return nil + } + + err = api.PullRequestClose(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title) + + return nil +} diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go new file mode 100644 index 000000000..1bb530637 --- /dev/null +++ b/pkg/cmd/pr/close/close_test.go @@ -0,0 +1,101 @@ +package close + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdClose(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPrClose(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 96, "title": "The title of the PR" } + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "96") + if err != nil { + t.Fatalf("error running command `pr close`: %v", err) + } + + r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrClose_alreadyClosed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } + } } } + `)) + + output, err := runCommand(http, true, "101") + if err != nil { + t.Fatalf("error running command `pr close`: %v", err) + } + + r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/command/pr_create.go b/pkg/cmd/pr/create/create.go similarity index 54% rename from command/pr_create.go rename to pkg/cmd/pr/create/create.go index b9efca0fa..c9e1fb6ab 100644 --- a/command/pr_create.go +++ b/pkg/cmd/pr/create/create.go @@ -1,9 +1,9 @@ -package command +package create import ( "errors" "fmt" - "net/url" + "net/http" "strings" "time" @@ -11,60 +11,116 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/context" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/githubtemplate" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) -type defaults struct { - Title string - Body string +type CreateOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + RepoOverride string + + Autofill bool + WebMode bool + + IsDraft bool + Title string + TitleProvided bool + Body string + BodyProvided bool + BaseBranch string + + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string } -func computeDefaults(baseRef, headRef string) (defaults, error) { - commits, err := git.Commits(baseRef, headRef) +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pull request", + Example: heredoc.Doc(` + $ gh pr create --title "The bug is fixed" --body "Everything works again" + $ gh issue create --label "bug,help wanted" + $ gh issue create --label bug --label "help wanted" + $ gh pr create --reviewer monalisa,hubot + $ gh pr create --project "Roadmap" + $ gh pr create --base develop + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + opts.TitleProvided = cmd.Flags().Changed("title") + opts.BodyProvided = cmd.Flags().Changed("body") + opts.RepoOverride, _ = cmd.Flags().GetString("repo") + + isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + if !isTerminal && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { + return errors.New("--title or --fill required when not attached to a terminal") + } + + if opts.IsDraft && opts.WebMode { + return errors.New("the --draft flag is not supported with --web") + } + if len(opts.Reviewers) > 0 && opts.WebMode { + return errors.New("the --reviewer flag is not supported with --web") + } + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + fl := cmd.Flags() + fl.BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark pull request as a draft") + fl.StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") + fl.StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The branch into which you want your code merged") + fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") + fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") + fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`") + fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") + fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") + fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") + fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") + + return cmd +} + +func createRun(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() if err != nil { - return defaults{}, err + return err } + client := api.NewClientFromHTTP(httpClient) - out := defaults{} - - if len(commits) == 1 { - out.Title = commits[0].Title - body, err := git.CommitBody(commits[0].Sha) - if err != nil { - return defaults{}, err - } - out.Body = body - } else { - out.Title = utils.Humanize(headRef) - - body := "" - for i := len(commits) - 1; i >= 0; i-- { - body += fmt.Sprintf("- %s\n", commits[i].Title) - } - out.Body = body - } - - return out, nil -} - -func prCreate(cmd *cobra.Command, _ []string) error { - ctx := contextForCommand(cmd) - remotes, err := ctx.Remotes() + remotes, err := opts.Remotes() if err != nil { return err } - client, err := apiClientForContext(ctx) - if err != nil { - return fmt.Errorf("could not initialize API client: %w", err) - } - - baseRepoOverride, _ := cmd.Flags().GetString("repo") - repoContext, err := context.ResolveRemotesToRepos(remotes, client, baseRepoOverride) + repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return err } @@ -74,7 +130,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("could not determine base repository: %w", err) } - headBranch, err := ctx.Branch() + headBranch, err := opts.Branch() if err != nil { return fmt.Errorf("could not determine the current branch: %w", err) } @@ -102,10 +158,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - baseBranch, err := cmd.Flags().GetString("base") - if err != nil { - return err - } + baseBranch := opts.BaseBranch if baseBranch == "" { baseBranch = baseRepo.DefaultBranchRef.Name } @@ -114,39 +167,12 @@ func prCreate(cmd *cobra.Command, _ []string) error { } if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) + fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } - title, err := cmd.Flags().GetString("title") - if err != nil { - return fmt.Errorf("could not parse title: %w", err) - } - body, err := cmd.Flags().GetString("body") - if err != nil { - return fmt.Errorf("could not parse body: %w", err) - } - - reviewers, err := cmd.Flags().GetStringSlice("reviewer") - if err != nil { - return fmt.Errorf("could not parse reviewers: %w", err) - } - assignees, err := cmd.Flags().GetStringSlice("assignee") - if err != nil { - return fmt.Errorf("could not parse assignees: %w", err) - } - labelNames, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return fmt.Errorf("could not parse labels: %w", err) - } - projectNames, err := cmd.Flags().GetStringSlice("project") - if err != nil { - return fmt.Errorf("could not parse projects: %w", err) - } var milestoneTitles []string - if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil { - return fmt.Errorf("could not parse milestone: %w", err) - } else if milestoneTitle != "" { - milestoneTitles = append(milestoneTitles, milestoneTitle) + if opts.Milestone != "" { + milestoneTitles = []string{opts.Milestone} } baseTrackingBranch := baseBranch @@ -155,23 +181,16 @@ func prCreate(cmd *cobra.Command, _ []string) error { } defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch) - isWeb, err := cmd.Flags().GetBool("web") - if err != nil { - return fmt.Errorf("could not parse web: %q", err) - } + title := opts.Title + body := opts.Body - autofill, err := cmd.Flags().GetBool("fill") - if err != nil { - return fmt.Errorf("could not parse fill: %q", err) - } - - action := SubmitAction - if isWeb { - action = PreviewAction + action := shared.SubmitAction + if opts.WebMode { + action = shared.PreviewAction if (title == "" || body == "") && defaultsErr != nil { return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr) } - } else if autofill { + } else if opts.Autofill { if defaultsErr != nil { return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr) } @@ -179,7 +198,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { body = defs.Body } - if !isWeb { + if !opts.WebMode { headBranchLabel := headBranch if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) { headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) @@ -194,46 +213,37 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - isDraft, err := cmd.Flags().GetBool("draft") - if err != nil { - return fmt.Errorf("could not parse draft: %w", err) - } + isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() - if !isWeb && !autofill { + if !opts.WebMode && !opts.Autofill { message := "\nCreating pull request for %s into %s in %s\n\n" - if isDraft { + if opts.IsDraft { message = "\nCreating draft pull request for %s into %s in %s\n\n" } - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), message, + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, message, utils.Cyan(headBranch), utils.Cyan(baseBranch), ghrepo.FullName(baseRepo)) if (title == "" || body == "") && defaultsErr != nil { - fmt.Fprintf(colorableErr(cmd), "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr) + fmt.Fprintf(opts.IO.ErrOut, "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr) } } } - tb := issueMetadataState{ - Type: prMetadata, - Reviewers: reviewers, - Assignees: assignees, - Labels: labelNames, - Projects: projectNames, + tb := shared.IssueMetadataState{ + Type: shared.PRMetadata, + Reviewers: opts.Reviewers, + Assignees: opts.Assignees, + Labels: opts.Labels, + Projects: opts.Projects, Milestones: milestoneTitles, } - if !connectedToTerminal(cmd) { - if !isWeb && (!cmd.Flags().Changed("title") && !autofill) { - return errors.New("--title or --fill required when not attached to a tty") - } - } + interactive := isTerminal && !(opts.TitleProvided && opts.BodyProvided) - interactive := connectedToTerminal(cmd) && !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) - - if !isWeb && !autofill && interactive { + if !opts.WebMode && !opts.Autofill && interactive { var nonLegacyTemplateFiles []string var legacyTemplateFile *string if rootDir, err := git.ToplevelDir(); err == nil { @@ -241,15 +251,21 @@ func prCreate(cmd *cobra.Command, _ []string) error { nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE") legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE") } - err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage()) + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = shared.TitleBodySurvey(opts.IO, editorCommand, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage()) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } action = tb.Action - if action == CancelAction { - fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") + if action == shared.CancelAction { + fmt.Fprintln(opts.IO.ErrOut, "Discarding.") return nil } @@ -261,17 +277,10 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - if action == SubmitAction && title == "" { + if action == shared.SubmitAction && title == "" { return errors.New("pull request title must not be blank") } - if isDraft && isWeb { - return errors.New("the --draft flag is not supported with --web") - } - if len(reviewers) > 0 && isWeb { - return errors.New("the --reviewer flag is not supported with --web") - } - didForkRepo := false // if a head repository could not be determined so far, automatically create // one by forking the base repository @@ -303,7 +312,13 @@ func prCreate(cmd *cobra.Command, _ []string) error { // In either case, we want to add the head repo as a new git remote so we // can push to it. if headRemote == nil { - headRepoURL := formatRemoteURL(cmd, headRepo) + cfg, err := opts.Config() + if err != nil { + return err + } + cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol") + + headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) // TODO: prevent clashes with another remote of a same name gitRemote, err := git.AddRemote("fork", headRepoURL) @@ -326,7 +341,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { pushTries++ // first wait 2 seconds after forking, then 4s, then 6s waitSeconds := 2 * pushTries - fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) + fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) time.Sleep(time.Duration(waitSeconds) * time.Second) continue } @@ -336,16 +351,16 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - if action == SubmitAction { + if action == shared.SubmitAction { params := map[string]interface{}{ "title": title, "body": body, - "draft": isDraft, + "draft": opts.IsDraft, "baseRefName": baseBranch, "headRefName": headBranchLabel, } - err = addMetadataToIssueParams(client, baseRepo, params, &tb) + err = shared.AddMetadataToIssueParams(client, baseRepo, params, &tb) if err != nil { return err } @@ -355,19 +370,14 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to create pull request: %w", err) } - fmt.Fprintln(cmd.OutOrStdout(), pr.URL) - } else if action == PreviewAction { - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, assignees, labelNames, projectNames, milestone) + fmt.Fprintln(opts.IO.Out, pr.URL) + } else if action == shared.PreviewAction { + openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones) if err != nil { return err } - if connectedToTerminal(cmd) { - // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } else { @@ -377,6 +387,34 @@ func prCreate(cmd *cobra.Command, _ []string) error { return nil } +func computeDefaults(baseRef, headRef string) (shared.Defaults, error) { + out := shared.Defaults{} + + commits, err := git.Commits(baseRef, headRef) + if err != nil { + return out, err + } + + if len(commits) == 1 { + out.Title = commits[0].Title + body, err := git.CommitBody(commits[0].Sha) + if err != nil { + return out, err + } + out.Body = body + } else { + out.Title = utils.Humanize(headRef) + + body := "" + for i := len(commits) - 1; i >= 0; i-- { + body += fmt.Sprintf("- %s\n", commits[i].Title) + } + out.Body = body + } + + return out, nil +} + func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef { refsForLookup := []string{"HEAD"} var trackingRefs []git.TrackingRef @@ -418,73 +456,11 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr return nil } -func withPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestone string) (string, error) { - u, err := url.Parse(baseURL) - if err != nil { - return "", err - } - q := u.Query() - if title != "" { - q.Set("title", title) - } - if body != "" { - q.Set("body", body) - } - if len(assignees) > 0 { - q.Set("assignees", strings.Join(assignees, ",")) - } - if len(labels) > 0 { - q.Set("labels", strings.Join(labels, ",")) - } - if len(projects) > 0 { - q.Set("projects", strings.Join(projects, ",")) - } - if milestone != "" { - q.Set("milestone", milestone) - } - u.RawQuery = q.Encode() - return u.String(), nil -} - -func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestone string) (string, error) { +func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestones []string) (string, error) { u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", base, head) - url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone) + url, err := shared.WithPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestones) if err != nil { return "", err } return url, nil } - -var prCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a pull request", - Args: cmdutil.NoArgsQuoteReminder, - RunE: prCreate, - Example: heredoc.Doc(` - $ gh pr create --title "The bug is fixed" --body "Everything works again" - $ gh issue create --label "bug,help wanted" - $ gh issue create --label bug --label "help wanted" - $ gh pr create --reviewer monalisa,hubot - $ gh pr create --project "Roadmap" - $ gh pr create --base develop - `), -} - -func init() { - prCreateCmd.Flags().BoolP("draft", "d", false, - "Mark pull request as a draft") - prCreateCmd.Flags().StringP("title", "t", "", - "Supply a title. Will prompt for one otherwise.") - prCreateCmd.Flags().StringP("body", "b", "", - "Supply a body. Will prompt for one otherwise.") - prCreateCmd.Flags().StringP("base", "B", "", - "The branch into which you want your code merged") - prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request") - prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info") - - prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviews from people by their `login`") - prCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`") - prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`") - prCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the pull request to projects by `name`") - prCreateCmd.Flags().StringP("milestone", "m", "", "Add the pull request to a milestone by `name`") -} diff --git a/command/pr_create_test.go b/pkg/cmd/pr/create/create_test.go similarity index 88% rename from command/pr_create_test.go rename to pkg/cmd/pr/create/create_test.go index c6daf8bef..e50a72c63 100644 --- a/command/pr_create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1,25 +1,93 @@ -package command +package create import ( "bytes" "encoding/json" "io/ioutil" + "net/http" + "reflect" "strings" "testing" "github.com/cli/cli/context" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" + "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + Remotes: func() (context.Remotes, error) { + if remotes != nil { + return remotes, nil + } + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdCreate(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + func TestPRCreate_nontty_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -36,8 +104,8 @@ func TestPRCreate_nontty_web(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser - output, err := RunCommand(`pr create --web`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `--web`) + require.NoError(t, err) eq(t, output.String(), "") eq(t, output.Stderr(), "") @@ -50,33 +118,23 @@ func TestPRCreate_nontty_web(t *testing.T) { } func TestPRCreate_nontty_insufficient_flags(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + defer http.Verify(t) - output, err := RunCommand("pr create") + output, err := runCommand(http, nil, "feature", false, "") if err == nil { t.Fatal("expected error") } - assert.Equal(t, "--title or --fill required when not attached to a tty", err.Error()) + assert.Equal(t, "--title or --fill required when not attached to a terminal", err.Error()) assert.Equal(t, "", output.String()) } func TestPRCreate_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -101,8 +159,8 @@ func TestPRCreate_nontty(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -129,9 +187,9 @@ func TestPRCreate_nontty(t *testing.T) { } func TestPRCreate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -156,8 +214,8 @@ func TestPRCreate(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -183,8 +241,6 @@ func TestPRCreate(t *testing.T) { } func TestPRCreate_metadata(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() defer http.Verify(t) @@ -301,16 +357,16 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) + output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_withForking(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponseWithPermission("OWNER", "REPO", "READ") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -344,17 +400,17 @@ func TestPRCreate_withForking(t *testing.T) { cs.Stub("") // git remote add cs.Stub("") // git push - output, err := RunCommand(`pr create -t title -b body`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-t title -b body`) + require.NoError(t, err) eq(t, http.Requests[3].URL.Path, "/repos/OWNER/REPO/forks") eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_alreadyExists(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -376,7 +432,7 @@ func TestPRCreate_alreadyExists(t *testing.T) { cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - _, err := RunCommand(`pr create`) + _, err := runCommand(http, nil, "feature", true, ``) if err == nil { t.Fatal("error expected, got nil") } @@ -386,9 +442,9 @@ func TestPRCreate_alreadyExists(t *testing.T) { } func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -412,16 +468,16 @@ func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git rev-parse - _, err := RunCommand(`pr create -BanotherBase -t"cool" -b"nah"`) + _, err := runCommand(http, nil, "feature", true, `-BanotherBase -t"cool" -b"nah"`) if err != nil { t.Errorf("got unexpected error %q", err) } } func TestPRCreate_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -438,8 +494,8 @@ func TestPRCreate_web(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser - output, err := RunCommand(`pr create --web`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `--web`) + require.NoError(t, err) eq(t, output.String(), "") eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n") @@ -451,9 +507,8 @@ func TestPRCreate_web(t *testing.T) { } func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` @@ -479,7 +534,7 @@ func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") @@ -487,17 +542,20 @@ func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { } func TestPRCreate_cross_repo_same_branch(t *testing.T) { - defer stubTerminal(true)() - ctx := context.NewBlank() - ctx.SetBranch("default") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - "fork": "MYSELF/REPO", - }) - initContext = func() context.Context { - return ctx + remotes := context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + { + Remote: &git.Remote{Name: "fork"}, + Repo: ghrepo.New("MYSELF", "REPO"), + }, } + http := initFakeHTTP() + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` { "data": { "repo_000": { "id": "REPOID0", @@ -546,8 +604,8 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "cross repo" -b "same branch"`) - eq(t, err, nil) + output, err := runCommand(http, remotes, "default", true, `-t "cross repo" -b "same branch"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) reqBody := struct { @@ -575,9 +633,9 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) { } func TestPRCreate_survey_defaults_multicommit(t *testing.T) { - initBlankContext("", "OWNER/REPO", "cool_bug-fixes") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -623,8 +681,8 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) - eq(t, err, nil) + output, err := runCommand(http, nil, "cool_bug-fixes", true, ``) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -652,10 +710,9 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { } func TestPRCreate_survey_defaults_monocommit(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() defer http.Verify(t) + http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(` { "data": { "repository": { "forks": { "nodes": [ @@ -708,15 +765,15 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) + output, err := runCommand(http, nil, "feature", true, ``) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_survey_autofill_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -744,8 +801,8 @@ func TestPRCreate_survey_autofill_nontty(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser open - output, err := RunCommand(`pr create -f`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `-f`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -775,9 +832,9 @@ func TestPRCreate_survey_autofill_nontty(t *testing.T) { } func TestPRCreate_survey_autofill(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -805,8 +862,8 @@ func TestPRCreate_survey_autofill(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser open - output, err := RunCommand(`pr create -f`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-f`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -834,9 +891,9 @@ func TestPRCreate_survey_autofill(t *testing.T) { } func TestPRCreate_defaults_error_autofill(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") cs, cmdTeardown := test.InitCmdStubber() @@ -847,15 +904,15 @@ func TestPRCreate_defaults_error_autofill(t *testing.T) { cs.Stub("") // git status cs.Stub("") // git log - _, err := RunCommand("pr create -f") + _, err := runCommand(http, nil, "feature", true, "-f") eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") } func TestPRCreate_defaults_error_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") cs, cmdTeardown := test.InitCmdStubber() @@ -866,15 +923,15 @@ func TestPRCreate_defaults_error_web(t *testing.T) { cs.Stub("") // git status cs.Stub("") // git log - _, err := RunCommand("pr create -w") + _, err := runCommand(http, nil, "feature", true, "-w") eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") } func TestPRCreate_defaults_error_interactive(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -917,8 +974,8 @@ func TestPRCreate_defaults_error_interactive(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, ``) + require.NoError(t, err) stderr := string(output.Stderr()) eq(t, strings.Contains(stderr, "warning: could not compute title or body defaults: could not find any commits"), true) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index c38e7c78b..3d9d32083 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -31,7 +31,6 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman opts := &DiffOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - BaseRepo: f.BaseRepo, Remotes: f.Remotes, Branch: f.Branch, } @@ -41,6 +40,9 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman Short: "View changes in a pull request", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + if len(args) > 0 { opts.SelectorArg = args[0] } diff --git a/test/fixtures/prList.json b/pkg/cmd/pr/list/fixtures/prList.json similarity index 100% rename from test/fixtures/prList.json rename to pkg/cmd/pr/list/fixtures/prList.json diff --git a/test/fixtures/prListWithDuplicates.json b/pkg/cmd/pr/list/fixtures/prListWithDuplicates.json similarity index 100% rename from test/fixtures/prListWithDuplicates.json rename to pkg/cmd/pr/list/fixtures/prListWithDuplicates.json diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go new file mode 100644 index 000000000..fd1ebb2b9 --- /dev/null +++ b/pkg/cmd/pr/list/list.go @@ -0,0 +1,170 @@ +package list + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + WebMode bool + LimitResults int + State string + BaseBranch string + Labels []string + Assignee string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List and filter pull requests in this repository", + Example: heredoc.Doc(` + $ gh pr list --limit 999 + $ gh pr list --state closed + $ gh pr list --label "priority 1" --label "bug" + $ gh pr list --web + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if opts.LimitResults < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid value for --limit: %v", opts.LimitResults)} + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the pull requests") + cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of items to fetch") + cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|merged|all}") + cmd.Flags().StringVarP(&opts.BaseBranch, "base", "B", "", "Filter by base branch") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels") + cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.WebMode { + prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") + openURL, err := shared.ListURLWithQuery(prListURL, shared.FilterOptions{ + Entity: "pr", + State: opts.State, + Assignee: opts.Assignee, + Labels: opts.Labels, + BaseBranch: opts.BaseBranch, + }) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + var graphqlState []string + switch opts.State { + case "open": + graphqlState = []string{"OPEN"} + case "closed": + graphqlState = []string{"CLOSED", "MERGED"} + case "merged": + graphqlState = []string{"MERGED"} + case "all": + graphqlState = []string{"OPEN", "CLOSED", "MERGED"} + default: + return fmt.Errorf("invalid state: %s", opts.State) + } + + params := map[string]interface{}{ + "state": graphqlState, + } + if len(opts.Labels) > 0 { + params["labels"] = opts.Labels + } + if opts.BaseBranch != "" { + params["baseBranch"] = opts.BaseBranch + } + if opts.Assignee != "" { + params["assignee"] = opts.Assignee + } + + listResult, err := api.PullRequestList(apiClient, baseRepo, params, opts.LimitResults) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.BaseBranch != "" || opts.Assignee != "" + title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + } + + table := utils.NewTablePrinter(opts.IO) + for _, pr := range listResult.PullRequests { + prNum := strconv.Itoa(pr.Number) + if table.IsTTY() { + prNum = "#" + prNum + } + table.AddField(prNum, nil, shared.ColorFuncForPR(pr)) + table.AddField(text.ReplaceExcessiveWhitespace(pr.Title), nil, nil) + table.AddField(pr.HeadLabel(), nil, utils.Cyan) + if !table.IsTTY() { + table.AddField(prStateWithDraft(&pr), nil, nil) + } + table.EndRow() + } + err = table.Render() + if err != nil { + return err + } + + return nil +} + +func prStateWithDraft(pr *api.PullRequest) string { + if pr.IsDraft && pr.State == "OPEN" { + return "DRAFT" + } + + return pr.State +} diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go new file mode 100644 index 000000000..5faf42b57 --- /dev/null +++ b/pkg/cmd/pr/list/list_test.go @@ -0,0 +1,246 @@ +package list + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdList(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPRList(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, ` +Showing 3 of 3 open pull requests in OWNER/REPO + +`, output.Stderr()) + + lines := strings.Split(output.String(), "\n") + res := []*regexp.Regexp{ + regexp.MustCompile(`#32.*New feature.*feature`), + regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), + regexp.MustCompile(`#28.*Improve documentation.*docs`), + } + + for i, r := range res { + if !r.MatchString(lines[i]) { + t.Errorf("%s did not match %s", lines[i], r) + } + } +} + +func TestPRList_nontty(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) + + output, err := runCommand(http, false, "") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "", output.Stderr()) + + assert.Equal(t, `32 New feature feature DRAFT +29 Fixed bad bug hubot:bug-fix OPEN +28 Improve documentation docs MERGED +`, output.String()) +} + +func TestPRList_filtering(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) + assert.Equal(t, []interface{}{"one", "two", "three"}, params["labels"].([]interface{})) + })) + + output, err := runCommand(http, true, `-s all -l one,two -l three`) + if err != nil { + t.Fatal(err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), ` +No pull requests match your search in OWNER/REPO + +`) +} + +func TestPRList_filteringRemoveDuplicate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.FileResponse("./fixtures/prListWithDuplicates.json")) + + output, err := runCommand(http, true, "-l one,two") + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(output.String(), "\n") + + res := []*regexp.Regexp{ + regexp.MustCompile(`#32.*New feature.*feature`), + regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), + regexp.MustCompile(`#28.*Improve documentation.*docs`), + } + + for i, r := range res { + if !r.MatchString(lines[i]) { + t.Errorf("%s did not match %s", lines[i], r) + } + } +} + +func TestPRList_filteringClosed(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) + })) + + _, err := runCommand(http, true, `-s closed`) + if err != nil { + t.Fatal(err) + } +} + +func TestPRList_filteringAssignee(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, `repo:OWNER/REPO assignee:hubot is:pr sort:created-desc is:merged label:"needs tests" base:"develop"`, params["q"].(string)) + })) + + _, err := runCommand(http, true, `-s merged -l "needs tests" -a hubot -B develop`) + if err != nil { + t.Fatal(err) + } +} + +func TestPRList_filteringAssigneeLabels(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + _, err := runCommand(http, true, `-l one,two -a hubot`) + if err == nil && err.Error() != "multiple labels with --assignee are not supported" { + t.Fatal(err) + } +} + +func TestPRList_withInvalidLimitFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + _, err := runCommand(http, true, `--limit=0`) + if err == nil && err.Error() != "invalid limit: 0" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestPRList_web(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "--web -a peter -l bug -l docs -L 10 -s merged -B trunk") + if err != nil { + t.Errorf("error running command `pr list` with `--web` flag: %v", err) + } + + expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk" + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, expectedURL) +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go new file mode 100644 index 000000000..b3c20b208 --- /dev/null +++ b/pkg/cmd/pr/merge/merge.go @@ -0,0 +1,274 @@ +package merge + +import ( + "errors" + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type MergeOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + DeleteBranch bool + DeleteLocalBranch bool + MergeMethod api.PullRequestMergeMethod + InteractiveMode bool +} + +func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command { + opts := &MergeOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + var ( + flagMerge bool + flagSquash bool + flagRebase bool + ) + + cmd := &cobra.Command{ + Use: "merge [ | | ]", + Short: "Merge a pull request", + Long: heredoc.Doc(` + Merge a pull request on GitHub. + + By default, the head branch of the pull request will get deleted on both remote and local repositories. + To retain the branch, use '--delete-branch=false'. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + methodFlags := 0 + if flagMerge { + opts.MergeMethod = api.PullRequestMergeMethodMerge + methodFlags++ + } + if flagRebase { + opts.MergeMethod = api.PullRequestMergeMethodRebase + methodFlags++ + } + if flagSquash { + opts.MergeMethod = api.PullRequestMergeMethodSquash + methodFlags++ + } + if methodFlags == 0 { + if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not attached to a terminal")} + } + opts.InteractiveMode = true + } else if methodFlags > 1 { + return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")} + } + + opts.DeleteLocalBranch = !cmd.Flags().Changed("repo") + + if runF != nil { + return runF(opts) + } + return mergeRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", true, "Delete the local and remote branch after merge") + cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch") + cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch") + cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch") + + return cmd +} + +func mergeRun(opts *MergeOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if pr.Mergeable == "CONFLICTING" { + err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", utils.Red("!"), pr.Number, pr.Title) + return err + } else if pr.Mergeable == "UNKNOWN" { + err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number, pr.Title) + return err + } else if pr.State == "MERGED" { + err := fmt.Errorf("%s Pull request #%d (%s) was already merged", utils.Red("!"), pr.Number, pr.Title) + return err + } + + mergeMethod := opts.MergeMethod + deleteBranch := opts.DeleteBranch + crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() + + if opts.InteractiveMode { + mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) + if err != nil { + return nil + } + } + + var action string + if mergeMethod == api.PullRequestMergeMethodRebase { + action = "Rebased and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) + } else if mergeMethod == api.PullRequestMergeMethodSquash { + action = "Squashed and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) + } else if mergeMethod == api.PullRequestMergeMethodMerge { + action = "Merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) + } else { + err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) + return err + } + + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + isTerminal := opts.IO.IsStdoutTTY() + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", utils.Magenta("✔"), action, pr.Number, pr.Title) + } + + if deleteBranch { + branchSwitchString := "" + + if opts.DeleteLocalBranch && !crossRepoPR { + currentBranch, err := opts.Branch() + if err != nil { + return err + } + + var branchToSwitchTo string + if currentBranch == pr.HeadRefName { + branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return err + } + err = git.CheckoutBranch(branchToSwitchTo) + if err != nil { + return err + } + } + + localBranchExists := git.HasLocalBranch(pr.HeadRefName) + if localBranchExists { + err = git.DeleteLocalBranch(pr.HeadRefName) + if err != nil { + err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err) + return err + } + } + + if branchToSwitchTo != "" { + branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo)) + } + } + + if !crossRepoPR { + err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) + var httpErr api.HTTPError + // The ref might have already been deleted by GitHub + if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { + err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err) + return err + } + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString) + } + } + + return nil +} + +func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { + mergeMethodQuestion := &survey.Question{ + Name: "mergeMethod", + Prompt: &survey.Select{ + Message: "What merge method would you like to use?", + Options: []string{"Create a merge commit", "Rebase and merge", "Squash and merge"}, + Default: "Create a merge commit", + }, + } + + qs := []*survey.Question{mergeMethodQuestion} + + if !crossRepoPR { + var message string + if deleteLocalBranch { + message = "Delete the branch locally and on GitHub?" + } else { + message = "Delete the branch on GitHub?" + } + + deleteBranchQuestion := &survey.Question{ + Name: "deleteBranch", + Prompt: &survey.Confirm{ + Message: message, + Default: true, + }, + } + qs = append(qs, deleteBranchQuestion) + } + + answers := struct { + MergeMethod int + DeleteBranch bool + }{} + + err := prompt.SurveyAsk(qs, &answers) + if err != nil { + return 0, false, fmt.Errorf("could not prompt: %w", err) + } + + var mergeMethod api.PullRequestMergeMethod + switch answers.MergeMethod { + case 0: + mergeMethod = api.PullRequestMergeMethodMerge + case 1: + mergeMethod = api.PullRequestMergeMethodRebase + case 2: + mergeMethod = api.PullRequestMergeMethodSquash + } + + deleteBranch := answers.DeleteBranch + return mergeMethod, deleteBranch, nil +} diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go new file mode 100644 index 000000000..b479e36a9 --- /dev/null +++ b/pkg/cmd/pr/merge/merge_test.go @@ -0,0 +1,505 @@ +package merge + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "master"}, + }, "github.com"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdMerge(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + cli = strings.TrimPrefix(cli, "pr merge") + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPrMerge(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") + + output, err := runCommand(http, "master", true, "pr merge 1 --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_nontty(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") + + output, err := runCommand(http, "master", false, "pr merge 1 --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) +} + +func TestPrMerge_nontty_insufficient_flags(t *testing.T) { + output, err := runCommand(nil, "master", false, "pr merge 1") + if err == nil { + t.Fatal("expected error") + } + + assert.Equal(t, "--merge, --rebase, or --squash required when not attached to a terminal", err.Error()) + assert.Equal(t, "", output.String()) +} + +func TestPrMerge_withRepoFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + output, err := runCommand(http, "master", true, "pr merge 1 --merge -R OWNER/REPO") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + assert.Equal(t, 0, len(cs.Calls)) + + r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_deleteBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git checkout master + cs.Stub("") // git rev-parse --verify blueberries` + cs.Stub("") // git branch -d + cs.Stub("") // git push origin --delete blueberries + + output, err := runCommand(http, "blueberries", true, `pr merge --merge --delete-branch`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) +} + +func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + // We don't expect the default branch to be checked out, just that blueberries is deleted + cs.Stub("") // git rev-parse --verify blueberries + cs.Stub("") // git branch -d blueberries + cs.Stub("") // git push origin --delete blueberries + + output, err := runCommand(http, "master", true, `pr merge --merge --delete-branch blueberries`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) +} + +func TestPrMerge_noPrNumberGiven(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge) + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "blueberries", true, "pr merge --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Merged pull request #10 \(Blueberries are a good fruit\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_rebase(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 2, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "REBASE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "master", true, "pr merge 2 --rebase") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Rebased and merged pull request #2 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_squash(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 3, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "master", true, "pr merge 3 --squash") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) +} + +func TestPrMerge_alreadyMerged(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} + } } }`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "master", true, "pr merge 4") + if err == nil { + t.Fatalf("expected an error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) + + if !r.MatchString(err.Error()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRMerge_interactive(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [{ + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"}, + "id": "THE-ID", + "number": 3 + }] } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git push origin --delete blueberries + cs.Stub("") // git branch -d + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "mergeMethod", + Value: 0, + }, + { + Name: "deleteBranch", + Value: true, + }, + }) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) +} + +func TestPrMerge_multipleMergeMethods(t *testing.T) { + _, err := runCommand(nil, "master", true, "1 --merge --squash") + if err == nil { + t.Fatal("expected error running `pr merge` with multiple merge methods") + } +} + +func TestPrMerge_multipleMergeMethods_nontty(t *testing.T) { + _, err := runCommand(nil, "master", false, "1 --merge --squash") + if err == nil { + t.Fatal("expected error running `pr merge` with multiple merge methods") + } +} diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go new file mode 100644 index 000000000..a4b746656 --- /dev/null +++ b/pkg/cmd/pr/pr.go @@ -0,0 +1,56 @@ +package pr + +import ( + "github.com/MakeNowJust/heredoc" + cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout" + cmdClose "github.com/cli/cli/pkg/cmd/pr/close" + cmdCreate "github.com/cli/cli/pkg/cmd/pr/create" + cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff" + cmdList "github.com/cli/cli/pkg/cmd/pr/list" + cmdMerge "github.com/cli/cli/pkg/cmd/pr/merge" + cmdReady "github.com/cli/cli/pkg/cmd/pr/ready" + cmdReopen "github.com/cli/cli/pkg/cmd/pr/reopen" + cmdReview "github.com/cli/cli/pkg/cmd/pr/review" + cmdStatus "github.com/cli/cli/pkg/cmd/pr/status" + cmdView "github.com/cli/cli/pkg/cmd/pr/view" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdPR(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "pr ", + Short: "Manage pull requests", + Long: "Work with GitHub pull requests", + Example: heredoc.Doc(` + $ gh pr checkout 353 + $ gh pr create --fill + $ gh pr view --web + `), + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + A pull request can be supplied as argument in any of the following formats: + - by number, e.g. "123"; + - by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or + - by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1". + `), + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdCheckout.NewCmdCheckout(f, nil)) + cmd.AddCommand(cmdClose.NewCmdClose(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdDiff.NewCmdDiff(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdMerge.NewCmdMerge(f, nil)) + cmd.AddCommand(cmdReady.NewCmdReady(f, nil)) + cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) + cmd.AddCommand(cmdReview.NewCmdReview(f, nil)) + cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + return cmd +} diff --git a/pkg/cmd/pr/ready/ready.go b/pkg/cmd/pr/ready/ready.go new file mode 100644 index 000000000..c5a248f19 --- /dev/null +++ b/pkg/cmd/pr/ready/ready.go @@ -0,0 +1,88 @@ +package ready + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReadyOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string +} + +func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Command { + opts := &ReadyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "ready [ | | ]", + Short: "Mark a pull request as ready for review", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return readyRun(opts) + }, + } + + return cmd +} + +func readyRun(opts *ReadyOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number) + return cmdutil.SilentError + } else if !pr.IsDraft { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", utils.Yellow("!"), pr.Number) + return nil + } + + err = api.PullRequestReady(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is marked as \"ready for review\"\n", utils.Green("✔"), pr.Number) + + return nil +} diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go new file mode 100644 index 000000000..8f5df5832 --- /dev/null +++ b/pkg/cmd/pr/ready/ready_test.go @@ -0,0 +1,135 @@ +package ready + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "main", nil + }, + } + + cmd := NewCmdReady(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRReady(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 444, "closed": false, "isDraft": true} + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "444") + if err != nil { + t.Fatalf("error running command `pr ready`: %v", err) + } + + r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReady_alreadyReady(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 445, "closed": false, "isDraft": false} + } } } + `)) + + output, err := runCommand(http, true, "445") + if err != nil { + t.Fatalf("error running command `pr ready`: %v", err) + } + + r := regexp.MustCompile(`Pull request #445 is already "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReady_closed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 446, "closed": true, "isDraft": true} + } } } + `)) + + output, err := runCommand(http, true, "446") + if err == nil { + t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err) + } + + r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/pkg/cmd/pr/reopen/reopen.go b/pkg/cmd/pr/reopen/reopen.go new file mode 100644 index 000000000..78f4a36ec --- /dev/null +++ b/pkg/cmd/pr/reopen/reopen.go @@ -0,0 +1,85 @@ +package reopen + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReopenOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command { + opts := &ReopenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "reopen { | | }", + Short: "Reopen a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return reopenRun(opts) + }, + } + + return cmd +} + +func reopenRun(opts *ReopenOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + if err != nil { + return err + } + + if pr.State == "MERGED" { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", utils.Red("!"), pr.Number, pr.Title) + return cmdutil.SilentError + } + + if !pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", utils.Yellow("!"), pr.Number, pr.Title) + return nil + } + + err = api.PullRequestReopen(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Reopened pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) + + return nil +} diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go new file mode 100644 index 000000000..24dfa488f --- /dev/null +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -0,0 +1,123 @@ +package reopen + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdReopen(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRReopen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "666") + if err != nil { + t.Fatalf("error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReopen_alreadyOpen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} + } } } + `)) + + output, err := runCommand(http, true, "666") + if err != nil { + t.Fatalf("error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReopen_alreadyMerged(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} + } } } + `)) + + output, err := runCommand(http, true, "666") + if err == nil { + t.Fatalf("expected an error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 61bfcea59..dba5dff10 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -40,7 +40,6 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, - BaseRepo: f.BaseRepo, Remotes: f.Remotes, Branch: f.Branch, } @@ -68,6 +67,9 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + if len(args) > 0 { opts.SelectorArg = args[0] } diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go new file mode 100644 index 000000000..31e7228b6 --- /dev/null +++ b/pkg/cmd/pr/shared/display.go @@ -0,0 +1,66 @@ +package shared + +import ( + "fmt" + "io" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/utils" +) + +func StateTitleWithColor(pr api.PullRequest) string { + prStateColorFunc := ColorFuncForPR(pr) + if pr.State == "OPEN" && pr.IsDraft { + return prStateColorFunc(strings.Title(strings.ToLower("Draft"))) + } + return prStateColorFunc(strings.Title(strings.ToLower(pr.State))) +} + +func ColorFuncForPR(pr api.PullRequest) func(string) string { + if pr.State == "OPEN" && pr.IsDraft { + return utils.Gray + } + return ColorFuncForState(pr.State) +} + +// ColorFuncForState returns a color function for a PR/Issue state +func ColorFuncForState(state string) func(string) string { + switch state { + case "OPEN": + return utils.Green + case "CLOSED": + return utils.Red + case "MERGED": + return utils.Magenta + default: + return nil + } +} + +func PrintHeader(w io.Writer, s string) { + fmt.Fprintln(w, utils.Bold(s)) +} + +func PrintMessage(w io.Writer, s string) { + fmt.Fprintln(w, utils.Gray(s)) +} + +func ListHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string { + if totalMatchCount == 0 { + if hasFilters { + return fmt.Sprintf("No %ss match your search in %s", itemName, repoName) + } + return fmt.Sprintf("There are no open %ss in %s", itemName, repoName) + } + + if hasFilters { + matchVerb := "match" + if totalMatchCount == 1 { + matchVerb = "matches" + } + return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb) + } + + return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, fmt.Sprintf("open %s", itemName)), repoName) +} diff --git a/pkg/cmd/pr/shared/display_test.go b/pkg/cmd/pr/shared/display_test.go new file mode 100644 index 000000000..d958d2265 --- /dev/null +++ b/pkg/cmd/pr/shared/display_test.go @@ -0,0 +1,114 @@ +package shared + +import "testing" + +func Test_listHeader(t *testing.T) { + type args struct { + repoName string + itemName string + matchCount int + totalMatchCount int + hasFilters bool + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no results", + args: args{ + repoName: "REPO", + itemName: "table", + matchCount: 0, + totalMatchCount: 0, + hasFilters: false, + }, + want: "There are no open tables in REPO", + }, + { + name: "no matches after filters", + args: args{ + repoName: "REPO", + itemName: "Luftballon", + matchCount: 0, + totalMatchCount: 0, + hasFilters: true, + }, + want: "No Luftballons match your search in REPO", + }, + { + name: "one result", + args: args{ + repoName: "REPO", + itemName: "genie", + matchCount: 1, + totalMatchCount: 23, + hasFilters: false, + }, + want: "Showing 1 of 23 open genies in REPO", + }, + { + name: "one result after filters", + args: args{ + repoName: "REPO", + itemName: "tiny cup", + matchCount: 1, + totalMatchCount: 23, + hasFilters: true, + }, + want: "Showing 1 of 23 tiny cups in REPO that match your search", + }, + { + name: "one result in total", + args: args{ + repoName: "REPO", + itemName: "chip", + matchCount: 1, + totalMatchCount: 1, + hasFilters: false, + }, + want: "Showing 1 of 1 open chip in REPO", + }, + { + name: "one result in total after filters", + args: args{ + repoName: "REPO", + itemName: "spicy noodle", + matchCount: 1, + totalMatchCount: 1, + hasFilters: true, + }, + want: "Showing 1 of 1 spicy noodle in REPO that matches your search", + }, + { + name: "multiple results", + args: args{ + repoName: "REPO", + itemName: "plant", + matchCount: 4, + totalMatchCount: 23, + hasFilters: false, + }, + want: "Showing 4 of 23 open plants in REPO", + }, + { + name: "multiple results after filters", + args: args{ + repoName: "REPO", + itemName: "boomerang", + matchCount: 4, + totalMatchCount: 23, + hasFilters: true, + }, + want: "Showing 4 of 23 boomerangs in REPO that match your search", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ListHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want { + t.Errorf("listHeader() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go new file mode 100644 index 000000000..616b4b358 --- /dev/null +++ b/pkg/cmd/pr/shared/params.go @@ -0,0 +1,165 @@ +package shared + +import ( + "fmt" + "net/url" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" +) + +func WithPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestones []string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + q := u.Query() + if title != "" { + q.Set("title", title) + } + if body != "" { + q.Set("body", body) + } + if len(assignees) > 0 { + q.Set("assignees", strings.Join(assignees, ",")) + } + if len(labels) > 0 { + q.Set("labels", strings.Join(labels, ",")) + } + if len(projects) > 0 { + q.Set("projects", strings.Join(projects, ",")) + } + if len(milestones) > 0 { + q.Set("milestone", milestones[0]) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error { + if !tb.HasMetadata() { + return nil + } + + if tb.MetadataResult == nil { + resolveInput := api.RepoResolveInput{ + Reviewers: tb.Reviewers, + Assignees: tb.Assignees, + Labels: tb.Labels, + Projects: tb.Projects, + Milestones: tb.Milestones, + } + + var err error + tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) + if err != nil { + return err + } + } + + assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) + if err != nil { + return fmt.Errorf("could not assign user: %w", err) + } + params["assigneeIds"] = assigneeIDs + + labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels) + if err != nil { + return fmt.Errorf("could not add label: %w", err) + } + params["labelIds"] = labelIDs + + projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) + if err != nil { + return fmt.Errorf("could not add to project: %w", err) + } + params["projectIds"] = projectIDs + + if len(tb.Milestones) > 0 { + milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0]) + if err != nil { + return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err) + } + params["milestoneId"] = milestoneID + } + + if len(tb.Reviewers) == 0 { + return nil + } + + var userReviewers []string + var teamReviewers []string + for _, r := range tb.Reviewers { + if strings.ContainsRune(r, '/') { + teamReviewers = append(teamReviewers, r) + } else { + userReviewers = append(userReviewers, r) + } + } + + userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["userReviewerIds"] = userReviewerIDs + + teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["teamReviewerIds"] = teamReviewerIDs + + return nil +} + +type FilterOptions struct { + Entity string + State string + Assignee string + Labels []string + Author string + BaseBranch string + Mention string + Milestone string +} + +func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { + u, err := url.Parse(listURL) + if err != nil { + return "", err + } + query := fmt.Sprintf("is:%s ", options.Entity) + if options.State != "all" { + query += fmt.Sprintf("is:%s ", options.State) + } + if options.Assignee != "" { + query += fmt.Sprintf("assignee:%s ", options.Assignee) + } + for _, label := range options.Labels { + query += fmt.Sprintf("label:%s ", quoteValueForQuery(label)) + } + if options.Author != "" { + query += fmt.Sprintf("author:%s ", options.Author) + } + if options.BaseBranch != "" { + query += fmt.Sprintf("base:%s ", options.BaseBranch) + } + if options.Mention != "" { + query += fmt.Sprintf("mentions:%s ", options.Mention) + } + if options.Milestone != "" { + query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.Milestone)) + } + q := u.Query() + q.Set("q", strings.TrimSuffix(query, " ")) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func quoteValueForQuery(v string) string { + if strings.ContainsAny(v, " \"\t\r\n") { + return fmt.Sprintf("%q", v) + } + return v +} diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go new file mode 100644 index 000000000..177ae0148 --- /dev/null +++ b/pkg/cmd/pr/shared/params_test.go @@ -0,0 +1,71 @@ +package shared + +import "testing" + +func Test_listURLWithQuery(t *testing.T) { + type args struct { + listURL string + options FilterOptions + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "blank", + args: args{ + listURL: "https://example.com/path?a=b", + options: FilterOptions{ + Entity: "issue", + State: "open", + }, + }, + want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen", + wantErr: false, + }, + { + name: "all", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + Assignee: "bo", + Author: "ka", + BaseBranch: "trunk", + Mention: "nu", + }, + }, + want: "https://example.com/path?q=is%3Aissue+is%3Aopen+assignee%3Abo+author%3Aka+base%3Atrunk+mentions%3Anu", + wantErr: false, + }, + { + name: "spaces in values", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Labels: []string{"docs", "help wanted"}, + Milestone: `Codename "What Was Missing"`, + }, + }, + want: "https://example.com/path?q=is%3Apr+is%3Aopen+label%3Adocs+label%3A%22help+wanted%22+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ListURLWithQuery(tt.args.listURL, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("listURLWithQuery() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/command/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go similarity index 88% rename from command/title_body_survey.go rename to pkg/cmd/pr/shared/title_body_survey.go index 94ec89914..3afeba803 100644 --- a/command/title_body_survey.go +++ b/pkg/cmd/pr/shared/title_body_survey.go @@ -1,4 +1,4 @@ -package command +package shared import ( "fmt" @@ -7,21 +7,26 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/githubtemplate" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" "github.com/cli/cli/utils" - "github.com/spf13/cobra" ) +type Defaults struct { + Title string + Body string +} + type Action int type metadataStateType int const ( - issueMetadata metadataStateType = iota - prMetadata + IssueMetadata metadataStateType = iota + PRMetadata ) -type issueMetadataState struct { +type IssueMetadataState struct { Type metadataStateType Body string @@ -38,7 +43,7 @@ type issueMetadataState struct { MetadataResult *api.RepoMetadataResult } -func (tb *issueMetadataState) HasMetadata() bool { +func (tb *IssueMetadataState) HasMetadata() bool { return len(tb.Reviewers) > 0 || len(tb.Assignees) > 0 || len(tb.Labels) > 0 || @@ -112,9 +117,9 @@ func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, for _, p := range nonLegacyTemplatePaths { templateNames = append(templateNames, githubtemplate.ExtractName(p)) } - if metadataType == issueMetadata { + if metadataType == IssueMetadata { templateNames = append(templateNames, "Open a blank issue") - } else if metadataType == prMetadata { + } else if metadataType == PRMetadata { templateNames = append(templateNames, "Open a blank pull request") } @@ -143,12 +148,8 @@ func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error { - editorCommand, err := determineEditor(cmd) - if err != nil { - return err - } - +// FIXME: this command has too many parameters and responsibilities +func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState *IssueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs Defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error { issueState.Title = defs.Title templateContents := "" @@ -198,7 +199,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie qs = append(qs, bodyQuestion) } - err = prompt.SurveyAsk(qs, issueState) + err := prompt.SurveyAsk(qs, issueState) if err != nil { return fmt.Errorf("could not prompt: %w", err) } @@ -249,7 +250,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - s := utils.Spinner(cmd.OutOrStderr()) + s := utils.Spinner(io.ErrOut) utils.StartSpinner(s) issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) utils.StopSpinner(s) @@ -297,7 +298,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no available reviewers") + fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } if isChosen("Assignees") { @@ -311,7 +312,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no assignable users") + fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } if isChosen("Labels") { @@ -325,7 +326,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no labels in the repository") + fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } if isChosen("Projects") { @@ -339,7 +340,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no projects to choose from") + fmt.Fprintln(io.ErrOut, "warning: no projects to choose from") } } if isChosen("Milestone") { @@ -357,7 +358,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no milestones in the repository") + fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } values := metadataValues{} diff --git a/test/fixtures/prStatus.json b/pkg/cmd/pr/status/fixtures/prStatus.json similarity index 100% rename from test/fixtures/prStatus.json rename to pkg/cmd/pr/status/fixtures/prStatus.json diff --git a/test/fixtures/prStatusChecks.json b/pkg/cmd/pr/status/fixtures/prStatusChecks.json similarity index 100% rename from test/fixtures/prStatusChecks.json rename to pkg/cmd/pr/status/fixtures/prStatusChecks.json diff --git a/test/fixtures/prStatusCurrentBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranch.json diff --git a/test/fixtures/prStatusCurrentBranchClosed.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosed.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchClosed.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosed.json diff --git a/test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json diff --git a/test/fixtures/prStatusCurrentBranchMerged.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMerged.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchMerged.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMerged.json diff --git a/test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go new file mode 100644 index 000000000..dffd2b412 --- /dev/null +++ b/pkg/cmd/pr/status/status.go @@ -0,0 +1,239 @@ +package status + +import ( + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + HasRepoOverride bool +} + +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show status of relevant pull requests", + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + opts.HasRepoOverride = cmd.Flags().Changed("repo") + + if runF != nil { + return runF(opts) + } + return statusRun(opts) + }, + } + + return cmd +} + +func statusRun(opts *StatusOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var currentBranch string + var currentPRNumber int + var currentPRHeadRef string + + if !opts.HasRepoOverride { + currentBranch, err = opts.Branch() + if err != nil && !errors.Is(err, git.ErrNotOnAnyBranch) { + return fmt.Errorf("could not query for pull request for current branch: %w", err) + } + + remotes, _ := opts.Remotes() + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes) + if err != nil { + return fmt.Errorf("could not query for pull request for current branch: %w", err) + } + } + + // the `@me` macro is available because the API lookup is ElasticSearch-based + currentUser := "@me" + prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) + if err != nil { + return err + } + + out := opts.IO.Out + + fmt.Fprintln(out, "") + fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo)) + fmt.Fprintln(out, "") + + shared.PrintHeader(out, "Current branch") + currentPR := prPayload.CurrentPR + if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch { + currentPR = nil + } + if currentPR != nil { + printPrs(out, 1, *currentPR) + } else if currentPRHeadRef == "" { + shared.PrintMessage(out, " There is no current branch") + } else { + shared.PrintMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]"))) + } + fmt.Fprintln(out) + + shared.PrintHeader(out, "Created by you") + if prPayload.ViewerCreated.TotalCount > 0 { + printPrs(out, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...) + } else { + shared.PrintMessage(out, " You have no open pull requests") + } + fmt.Fprintln(out) + + shared.PrintHeader(out, "Requesting a code review from you") + if prPayload.ReviewRequested.TotalCount > 0 { + printPrs(out, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...) + } else { + shared.PrintMessage(out, " You have no pull requests to review") + } + fmt.Fprintln(out) + + return nil +} + +func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) { + selector = prHeadRef + branchConfig := git.ReadBranchConfig(prHeadRef) + + // the branch is configured to merge a special PR head ref + prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + prNumber, _ = strconv.Atoi(m[1]) + return + } + + var branchOwner string + if branchConfig.RemoteURL != nil { + // the branch merges from a remote specified by URL + if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { + branchOwner = r.RepoOwner() + } + } else if branchConfig.RemoteName != "" { + // the branch merges from a remote specified by name + if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { + branchOwner = r.RepoOwner() + } + } + + if branchOwner != "" { + if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { + selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + } + // prepend `OWNER:` if this branch is pushed to a fork + if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { + selector = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) + } + } + + return +} + +func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) { + for _, pr := range prs { + prNumber := fmt.Sprintf("#%d", pr.Number) + + prStateColorFunc := utils.Green + if pr.IsDraft { + prStateColorFunc = utils.Gray + } else if pr.State == "MERGED" { + prStateColorFunc = utils.Magenta + } else if pr.State == "CLOSED" { + prStateColorFunc = utils.Red + } + + fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.ReplaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) + + checks := pr.ChecksStatus() + reviews := pr.ReviewStatus() + + if pr.State == "OPEN" { + reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired + if checks.Total > 0 || reviewStatus { + // show checks & reviews on their own line + fmt.Fprintf(w, "\n ") + } + + if checks.Total > 0 { + var summary string + if checks.Failing > 0 { + if checks.Failing == checks.Total { + summary = utils.Red("× All checks failing") + } else { + summary = utils.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total)) + } + } else if checks.Pending > 0 { + summary = utils.Yellow("- Checks pending") + } else if checks.Passing == checks.Total { + summary = utils.Green("✓ Checks passing") + } + fmt.Fprint(w, summary) + } + + if checks.Total > 0 && reviewStatus { + // add padding between checks & reviews + fmt.Fprint(w, " ") + } + + if reviews.ChangesRequested { + fmt.Fprint(w, utils.Red("+ Changes requested")) + } else if reviews.ReviewRequired { + fmt.Fprint(w, utils.Yellow("- Review required")) + } else if reviews.Approved { + fmt.Fprint(w, utils.Green("✓ Approved")) + } + } else { + fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(pr)) + } + + fmt.Fprint(w, "\n") + } + remaining := totalCount - len(prs) + if remaining > 0 { + fmt.Fprintf(w, utils.Gray(" And %d more\n"), remaining) + } +} diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go new file mode 100644 index 000000000..bc4f4e4f9 --- /dev/null +++ b/pkg/cmd/pr/status/status_test.go @@ -0,0 +1,310 @@ +package status + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + if branch == "" { + return "", git.ErrNotOnAnyBranch + } + return branch, nil + }, + } + + cmd := NewCmdStatus(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPRStatus(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatus.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedPrs := []*regexp.Regexp{ + regexp.MustCompile(`#8.*\[strawberries\]`), + regexp.MustCompile(`#9.*\[apples\]`), + regexp.MustCompile(`#10.*\[blueberries\]`), + regexp.MustCompile(`#11.*\[figs\]`), + } + + for _, r := range expectedPrs { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/", r) + } + } +} + +func TestPRStatus_reviewsAndChecks(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusChecks.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := []string{ + "✓ Checks passing + Changes requested", + "- Checks pending ✓ Approved", + "× 1/3 checks failing - Review required", + } + + for _, line := range expected { + if !strings.Contains(output.String(), line) { + t.Errorf("output did not contain %q: %q", line, output.String()) + } + } +} + +func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } + + unexpectedLines := []*regexp.Regexp{ + regexp.MustCompile(`#9 Blueberries are a good fruit \[blueberries\] - Merged`), + regexp.MustCompile(`#8 Blueberries are probably a good fruit \[blueberries\] - Closed`), + } + for _, r := range unexpectedLines { + if r.MatchString(output.String()) { + t.Errorf("output unexpectedly match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_defaultBranch_repoFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "-R OWNER/REPO") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\]`) + if expectedLine.MatchString(output.String()) { + t.Errorf("output not expected to match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Closed(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosed.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Closed`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Merged(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMerged.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Merged`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_blankSlate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := ` +Relevant pull requests in OWNER/REPO + +Current branch + There is no pull request associated with [blueberries] + +Created by you + You have no open pull requests + +Requesting a code review from you + You have no pull requests to review + +` + if output.String() != expected { + t.Errorf("expected %q, got %q", expected, output.String()) + } +} + +func TestPRStatus_detachedHead(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + + output, err := runCommand(http, "", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := ` +Relevant pull requests in OWNER/REPO + +Current branch + There is no current branch + +Created by you + You have no open pull requests + +Requesting a code review from you + You have no pull requests to review + +` + if output.String() != expected { + t.Errorf("expected %q, got %q", expected, output.String()) + } +} diff --git a/test/fixtures/prView.json b/pkg/cmd/pr/view/fixtures/prView.json similarity index 100% rename from test/fixtures/prView.json rename to pkg/cmd/pr/view/fixtures/prView.json diff --git a/test/fixtures/prViewPreview.json b/pkg/cmd/pr/view/fixtures/prViewPreview.json similarity index 100% rename from test/fixtures/prViewPreview.json rename to pkg/cmd/pr/view/fixtures/prViewPreview.json diff --git a/test/fixtures/prViewPreviewClosedState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewClosedState.json similarity index 100% rename from test/fixtures/prViewPreviewClosedState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewClosedState.json diff --git a/test/fixtures/prViewPreviewDraftState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewDraftState.json similarity index 100% rename from test/fixtures/prViewPreviewDraftState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewDraftState.json diff --git a/test/fixtures/prViewPreviewDraftStatebyBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json similarity index 100% rename from test/fixtures/prViewPreviewDraftStatebyBranch.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json diff --git a/test/fixtures/prViewPreviewMergedState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewMergedState.json similarity index 100% rename from test/fixtures/prViewPreviewMergedState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewMergedState.json diff --git a/test/fixtures/prViewPreviewWithMetadataByBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json similarity index 100% rename from test/fixtures/prViewPreviewWithMetadataByBranch.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json diff --git a/test/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json similarity index 100% rename from test/fixtures/prViewPreviewWithMetadataByNumber.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json diff --git a/test/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json similarity index 100% rename from test/fixtures/prViewPreviewWithReviewersByNumber.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json diff --git a/test/fixtures/prView_EmptyBody.json b/pkg/cmd/pr/view/fixtures/prView_EmptyBody.json similarity index 100% rename from test/fixtures/prView_EmptyBody.json rename to pkg/cmd/pr/view/fixtures/prView_EmptyBody.json diff --git a/test/fixtures/prView_NoActiveBranch.json b/pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json similarity index 100% rename from test/fixtures/prView_NoActiveBranch.json rename to pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go new file mode 100644 index 000000000..169d989f1 --- /dev/null +++ b/pkg/cmd/pr/view/view.go @@ -0,0 +1,357 @@ +package view + +import ( + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + BrowserMode bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "view [ | | ]", + Short: "View a pull request", + Long: heredoc.Doc(` + Display the title, body, and other information about a pull request. + + Without an argument, the pull request that belongs to the current branch + is displayed. + + With '--web', open the pull request in a web browser instead. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + openURL := pr.URL + connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() + + if opts.BrowserMode { + if connectedToTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + } + return utils.OpenInBrowser(openURL) + } + + if connectedToTerminal { + return printHumanPrPreview(opts.IO.Out, pr) + } + return printRawPrPreview(opts.IO.Out, pr) +} + +func printRawPrPreview(out io.Writer, pr *api.PullRequest) error { + reviewers := prReviewerList(*pr) + assignees := prAssigneeList(*pr) + labels := prLabelList(*pr) + projects := prProjectList(*pr) + + fmt.Fprintf(out, "title:\t%s\n", pr.Title) + fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) + fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labels) + fmt.Fprintf(out, "assignees:\t%s\n", assignees) + fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) + fmt.Fprintf(out, "projects:\t%s\n", projects) + fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title) + + fmt.Fprintln(out, "--") + fmt.Fprintln(out, pr.Body) + + return nil +} + +func printHumanPrPreview(out io.Writer, pr *api.PullRequest) error { + // Header (Title and State) + fmt.Fprintln(out, utils.Bold(pr.Title)) + fmt.Fprintf(out, "%s", shared.StateTitleWithColor(*pr)) + fmt.Fprintln(out, utils.Gray(fmt.Sprintf( + " • %s wants to merge %s into %s from %s", + pr.Author.Login, + utils.Pluralize(pr.Commits.TotalCount, "commit"), + pr.BaseRefName, + pr.HeadRefName, + ))) + fmt.Fprintln(out) + + // Metadata + if reviewers := prReviewerList(*pr); reviewers != "" { + fmt.Fprint(out, utils.Bold("Reviewers: ")) + fmt.Fprintln(out, reviewers) + } + if assignees := prAssigneeList(*pr); assignees != "" { + fmt.Fprint(out, utils.Bold("Assignees: ")) + fmt.Fprintln(out, assignees) + } + if labels := prLabelList(*pr); labels != "" { + fmt.Fprint(out, utils.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + if projects := prProjectList(*pr); projects != "" { + fmt.Fprint(out, utils.Bold("Projects: ")) + fmt.Fprintln(out, projects) + } + if pr.Milestone.Title != "" { + fmt.Fprint(out, utils.Bold("Milestone: ")) + fmt.Fprintln(out, pr.Milestone.Title) + } + + // Body + if pr.Body != "" { + fmt.Fprintln(out) + md, err := utils.RenderMarkdown(pr.Body) + if err != nil { + return err + } + fmt.Fprintln(out, md) + } + fmt.Fprintln(out) + + // Footer + fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL) + return nil +} + +// Ref. https://developer.github.com/v4/enum/pullrequestreviewstate/ +const ( + requestedReviewState = "REQUESTED" // This is our own state for review request + approvedReviewState = "APPROVED" + changesRequestedReviewState = "CHANGES_REQUESTED" + commentedReviewState = "COMMENTED" + dismissedReviewState = "DISMISSED" + pendingReviewState = "PENDING" +) + +type reviewerState struct { + Name string + State string +} + +// colorFuncForReviewerState returns a color function for a reviewer state +func colorFuncForReviewerState(state string) func(string) string { + switch state { + case requestedReviewState: + return utils.Yellow + case approvedReviewState: + return utils.Green + case changesRequestedReviewState: + return utils.Red + case commentedReviewState: + return func(str string) string { return str } // Do nothing + default: + return nil + } +} + +// formattedReviewerState formats a reviewerState with state color +func formattedReviewerState(reviewer *reviewerState) string { + state := reviewer.State + if state == dismissedReviewState { + // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes + // sense when displayed in an events timeline but not in the final tally. + state = commentedReviewState + } + stateColorFunc := colorFuncForReviewerState(state) + return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " "))) +} + +// prReviewerList generates a reviewer list with their last state +func prReviewerList(pr api.PullRequest) string { + reviewerStates := parseReviewers(pr) + reviewers := make([]string, 0, len(reviewerStates)) + + sortReviewerStates(reviewerStates) + + for _, reviewer := range reviewerStates { + reviewers = append(reviewers, formattedReviewerState(reviewer)) + } + + reviewerList := strings.Join(reviewers, ", ") + + return reviewerList +} + +// Ref. https://developer.github.com/v4/union/requestedreviewer/ +const teamTypeName = "Team" + +const ghostName = "ghost" + +// parseReviewers parses given Reviews and ReviewRequests +func parseReviewers(pr api.PullRequest) []*reviewerState { + reviewerStates := make(map[string]*reviewerState) + + for _, review := range pr.Reviews.Nodes { + if review.Author.Login != pr.Author.Login { + name := review.Author.Login + if name == "" { + name = ghostName + } + reviewerStates[name] = &reviewerState{ + Name: name, + State: review.State, + } + } + } + + // Overwrite reviewer's state if a review request for the same reviewer exists. + for _, reviewRequest := range pr.ReviewRequests.Nodes { + name := reviewRequest.RequestedReviewer.Login + if reviewRequest.RequestedReviewer.TypeName == teamTypeName { + name = reviewRequest.RequestedReviewer.Name + } + reviewerStates[name] = &reviewerState{ + Name: name, + State: requestedReviewState, + } + } + + // Convert map to slice for ease of sort + result := make([]*reviewerState, 0, len(reviewerStates)) + for _, reviewer := range reviewerStates { + if reviewer.State == pendingReviewState { + continue + } + result = append(result, reviewer) + } + + return result +} + +// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically +func sortReviewerStates(reviewerStates []*reviewerState) { + sort.Slice(reviewerStates, func(i, j int) bool { + if reviewerStates[i].State == requestedReviewState && + reviewerStates[j].State != requestedReviewState { + return false + } + if reviewerStates[j].State == requestedReviewState && + reviewerStates[i].State != requestedReviewState { + return true + } + + return reviewerStates[i].Name < reviewerStates[j].Name + }) +} + +func prAssigneeList(pr api.PullRequest) string { + if len(pr.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) + for _, assignee := range pr.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { + list += ", …" + } + return list +} + +func prLabelList(pr api.PullRequest) string { + if len(pr.Labels.Nodes) == 0 { + return "" + } + + labelNames := make([]string, 0, len(pr.Labels.Nodes)) + for _, label := range pr.Labels.Nodes { + labelNames = append(labelNames, label.Name) + } + + list := strings.Join(labelNames, ", ") + if pr.Labels.TotalCount > len(pr.Labels.Nodes) { + list += ", …" + } + return list +} + +func prProjectList(pr api.PullRequest) string { + if len(pr.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) + for _, project := range pr.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { + list += ", …" + } + return list +} + +func prStateWithDraft(pr *api.PullRequest) string { + if pr.IsDraft && pr.State == "OPEN" { + return "DRAFT" + } + + return pr.State +} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go new file mode 100644 index 000000000..fa540fa62 --- /dev/null +++ b/pkg/cmd/pr/view/view_test.go @@ -0,0 +1,620 @@ +package view + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdView(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRView_Preview_nontty(t *testing.T) { + tests := map[string]struct { + branch string + args string + fixture string + expectedOutputs []string + }{ + "Open PR without metadata": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreview.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tOPEN\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `blueberries taste good`, + }, + }, + "Open PR with metadata by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`, + `assignees:\tmarseilles, monaco\n`, + `labels:\tone, two, three, four, five\n`, + `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `milestone:\tuluru\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR with reviewers by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tOPEN\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR with metadata by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\tmarseilles, monaco\n`, + `labels:\tone, two, three, four, five\n`, + `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, + `milestone:\tuluru\n`, + `blueberries taste good`, + }, + }, + "Open PR for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\t\n`, + `labels:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR wth empty body for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView_EmptyBody.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\t\n`, + `labels:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + }, + }, + "Closed PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewClosedState.json", + expectedOutputs: []string{ + `state:\tCLOSED\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Merged PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewMergedState.json", + expectedOutputs: []string{ + `state:\tMERGED\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Draft PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewDraftState.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tDRAFT\n`, + `author:\tnobody\n`, + `labels:`, + `assignees:`, + `projects:`, + `milestone:`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Draft PR by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit\n`, + `state:\tDRAFT\n`, + `author:\tnobody\n`, + `labels:`, + `assignees:`, + `projects:`, + `milestone:`, + `\*\*blueberries taste good\*\*`, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, tc.branch, false, tc.args) + if err != nil { + t.Errorf("error running command `%v`: %v", tc.args, err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestPRView_Preview(t *testing.T) { + tests := map[string]struct { + branch string + args string + fixture string + expectedOutputs []string + }{ + "Open PR without metadata": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreview.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Open.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Open PR with metadata by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Open.*nobody wants to merge 12 commits into master from blueberries`, + `Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `Milestone:.*uluru\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + }, + }, + "Open PR with reviewers by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + }, + }, + "Open PR with metadata by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, + `Milestone:.*uluru\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`, + }, + }, + "Open PR for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + "Open PR wth empty body for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView_EmptyBody.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + "Closed PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewClosedState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Closed.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Merged PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewMergedState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Merged.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Draft PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewDraftState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Draft.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Draft PR by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Draft.*nobody wants to merge 8 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, tc.branch, true, tc.args) + if err != nil { + t.Errorf("error running command `%v`: %v", tc.args, err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestPRView_web_currentBranch(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView.json")) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + switch strings.Join(cmd.Args, " ") { + case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: + return &test.OutputStub{} + default: + seenCmd = cmd + return &test.OutputStub{} + } + }) + defer restoreCmd() + + output, err := runCommand(http, "blueberries", true, "-w") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + if url != "https://github.com/OWNER/REPO/pull/10" { + t.Errorf("got: %q", url) + } +} + +func TestPRView_web_noResultsForBranch(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView_NoActiveBranch.json")) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + switch strings.Join(cmd.Args, " ") { + case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: + return &test.OutputStub{} + default: + seenCmd = cmd + return &test.OutputStub{} + } + }) + defer restoreCmd() + + _, err := runCommand(http, "blueberries", true, "-w") + if err == nil || err.Error() != `no open pull requests found for branch "blueberries"` { + t.Errorf("error running command `pr view`: %v", err) + } + + if seenCmd != nil { + t.Fatalf("unexpected command: %v", seenCmd.Args) + } +} + +func TestPRView_web_numberArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w 23") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_numberArgWithHash(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, `-w "#23"`) + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_urlArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w https://github.com/OWNER/REPO/pull/23/files") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_branchArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": false, + "url": "https://github.com/OWNER/REPO/pull/23" } + ] } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w blueberries") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_branchWithOwnerArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": true, + "headRepositoryOwner": { "login": "hubot" }, + "url": "https://github.com/hubot/REPO/pull/23" } + ] } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w hubot:blueberries") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/hubot/REPO/pull/23") +} diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index ef82d5081..239fbc21c 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -1,6 +1,7 @@ package clone import ( + "fmt" "net/http" "strings" @@ -13,6 +14,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) type CloneOptions struct { @@ -33,16 +35,18 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm } cmd := &cobra.Command{ - Use: "clone []", + DisableFlagsInUseLine: true, + + Use: "clone [] [-- ...]", Args: cobra.MinimumNArgs(1), Short: "Clone a repository locally", - Long: heredoc.Doc( - `Clone a GitHub repository locally. + Long: heredoc.Doc(` + Clone a GitHub repository locally. If the "OWNER/" portion of the "OWNER/REPO" repository argument is omitted, it defaults to the name of the authenticating user. - To pass 'git clone' flags, separate them with '--'. + Pass additional 'git clone' flags by listing them after '--'. `), RunE: func(cmd *cobra.Command, args []string) error { opts.Repository = args[0] @@ -56,6 +60,13 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm }, } + cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } + return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)} + }) + return cmd } @@ -65,23 +76,16 @@ func cloneRun(opts *CloneOptions) error { return err } - // TODO This is overly wordy and I'd like to streamline this. cfg, err := opts.Config() if err != nil { return err } - // TODO: GHE support - protocol, err := cfg.Get("", "git_protocol") - if err != nil { - return err - } apiClient := api.NewClientFromHTTP(httpClient) cloneURL := opts.Repository if !strings.Contains(cloneURL, ":") { if !strings.Contains(cloneURL, "/") { - // TODO: GHE compat - currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default()) + currentUser, err := api.CurrentLoginName(apiClient, ghinstance.OverridableDefault()) if err != nil { return err } @@ -92,6 +96,10 @@ func cloneRun(opts *CloneOptions) error { return err } + protocol, err := cfg.Get(repo.RepoHost(), "git_protocol") + if err != nil { + return err + } cloneURL = ghrepo.FormatRemoteURL(repo, protocol) } @@ -117,9 +125,13 @@ func cloneRun(opts *CloneOptions) error { } if parentRepo != nil { + protocol, err := cfg.Get(parentRepo.RepoHost(), "git_protocol") + if err != nil { + return err + } upstreamURL := ghrepo.FormatRemoteURL(parentRepo, protocol) - err := git.AddUpstreamRemote(upstreamURL, cloneDir) + err = git.AddUpstreamRemote(upstreamURL, cloneDir) if err != nil { return err } diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index c732c8bfe..54aa2f1cb 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -179,3 +179,10 @@ func Test_RepoClone_withoutUsername(t *testing.T) { assert.Equal(t, 1, cs.Count) assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " ")) } + +func Test_RepoClone_flagError(t *testing.T) { + _, err := runCloneCommand(nil, "--depth 1 OWNER/REPO") + if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." { + t.Errorf("unexpected error %v", err) + } +} diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 3bf309c50..6dded0159 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -93,8 +93,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) error { projectDir, projectDirErr := git.ToplevelDir() - orgName := "" - name := opts.Name + var repoToCreate ghrepo.Interface isNameAnArg := false isDescEmpty := opts.Description == "" @@ -107,14 +106,14 @@ func createRun(opts *CreateOptions) error { if err != nil { return fmt.Errorf("argument error: %w", err) } - orgName = newRepo.RepoOwner() - name = newRepo.RepoName() + } else { + repoToCreate = ghrepo.New("", opts.Name) } } else { if projectDirErr != nil { return projectDirErr } - name = path.Base(projectDir) + repoToCreate = ghrepo.New("", path.Base(projectDir)) } enabledFlagCount := 0 @@ -165,9 +164,9 @@ func createRun(opts *CreateOptions) error { } input := repoCreateInput{ - Name: name, + Name: repoToCreate.RepoName(), Visibility: visibility, - OwnerID: orgName, + OwnerID: repoToCreate.RepoOwner(), TeamID: opts.Team, Description: opts.Description, HomepageURL: opts.Homepage, diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 113ca6b0a..3a46c0d9a 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/cli/cli/api" - "github.com/cli/cli/internal/ghinstance" ) // repoCreateInput represents input parameters for repoCreate @@ -23,7 +22,7 @@ type repoCreateInput struct { } // repoCreate creates a new GitHub repository -func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, error) { +func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*api.Repository, error) { apiClient := api.NewClientFromHTTP(client) var response struct { @@ -33,14 +32,14 @@ func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, er } if input.TeamID != "" { - orgID, teamID, err := resolveOrganizationTeam(apiClient, input.OwnerID, input.TeamID) + orgID, teamID, err := resolveOrganizationTeam(apiClient, hostname, input.OwnerID, input.TeamID) if err != nil { return nil, err } input.TeamID = teamID input.OwnerID = orgID } else if input.OwnerID != "" { - orgID, err := resolveOrganization(apiClient, input.OwnerID) + orgID, err := resolveOrganization(apiClient, hostname, input.OwnerID) if err != nil { return nil, err } @@ -51,9 +50,6 @@ func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, er "input": input, } - // TODO: GHE support - hostname := ghinstance.Default() - err := apiClient.GraphQL(hostname, ` mutation RepositoryCreate($input: CreateRepositoryInput!) { createRepository(input: $input) { @@ -74,24 +70,22 @@ func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, er } // using API v3 here because the equivalent in GraphQL needs `read:org` scope -func resolveOrganization(client *api.Client, orgName string) (string, error) { +func resolveOrganization(client *api.Client, hostname, orgName string) (string, error) { var response struct { NodeID string `json:"node_id"` } - // TODO: GHE support - err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("users/%s", orgName), nil, &response) + err := client.REST(hostname, "GET", fmt.Sprintf("users/%s", orgName), nil, &response) return response.NodeID, err } // using API v3 here because the equivalent in GraphQL needs `read:org` scope -func resolveOrganizationTeam(client *api.Client, orgName, teamSlug string) (string, string, error) { +func resolveOrganizationTeam(client *api.Client, hostname, orgName, teamSlug string) (string, string, error) { var response struct { NodeID string `json:"node_id"` Organization struct { NodeID string `json:"node_id"` } } - // TODO: GHE support - err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) + err := client.REST(hostname, "GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) return response.Organization.NodeID, response.NodeID, err } diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go index fb0fc6364..4b764572c 100644 --- a/pkg/cmd/repo/create/http_test.go +++ b/pkg/cmd/repo/create/http_test.go @@ -21,7 +21,7 @@ func Test_RepoCreate(t *testing.T) { HomepageURL: "http://example.com", } - _, err := repoCreate(httpClient, input) + _, err := repoCreate(httpClient, "github.com", input) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 4fad93fec..04f76fbb8 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -125,7 +125,6 @@ func forkRun(opts *ForkOptions) error { connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY() - greenCheck := utils.Green("✓") stderr := opts.IO.ErrOut s := utils.Spinner(stderr) stopSpinner := func() {} @@ -173,7 +172,7 @@ func forkRun(opts *ForkOptions) error { } } else { if connectedToTerminal { - fmt.Fprintf(stderr, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo))) + fmt.Fprintf(stderr, "%s Created fork %s\n", utils.GreenCheck(), utils.Bold(ghrepo.FullName(forkedRepo))) } } @@ -181,13 +180,11 @@ func forkRun(opts *ForkOptions) error { return nil } - // TODO This is overly wordy and I'd like to streamline this. cfg, err := opts.Config() if err != nil { return err } - // TODO: GHE support - protocol, err := cfg.Get("", "git_protocol") + protocol, err := cfg.Get(repoToFork.RepoHost(), "git_protocol") if err != nil { return err } @@ -199,7 +196,7 @@ func forkRun(opts *ForkOptions) error { } if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil { if connectedToTerminal { - fmt.Fprintf(stderr, "%s Using existing remote %s\n", greenCheck, utils.Bold(remote.Name)) + fmt.Fprintf(stderr, "%s Using existing remote %s\n", utils.GreenCheck(), utils.Bold(remote.Name)) } return nil } @@ -226,7 +223,7 @@ func forkRun(opts *ForkOptions) error { return err } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", greenCheck, utils.Bold(remoteName), utils.Bold(renameTarget)) + fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", utils.GreenCheck(), utils.Bold(remoteName), utils.Bold(renameTarget)) } } @@ -238,7 +235,7 @@ func forkRun(opts *ForkOptions) error { } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, utils.Bold(remoteName)) + fmt.Fprintf(stderr, "%s Added remote %s\n", utils.GreenCheck(), utils.Bold(remoteName)) } } } else { @@ -263,7 +260,7 @@ func forkRun(opts *ForkOptions) error { } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Cloned fork\n", greenCheck) + fmt.Fprintf(stderr, "%s Cloned fork\n", utils.GreenCheck()) } } } diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 7de2e3ee7..551c96641 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -2,22 +2,40 @@ package repo import ( "github.com/MakeNowJust/heredoc" + repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" + repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" + creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork" + repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" + "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) -var Cmd = &cobra.Command{ - Use: "repo ", - Short: "Create, clone, fork, and view repositories", - Long: `Work with GitHub repositories`, - Example: heredoc.Doc(` - $ gh repo create - $ gh repo clone cli/cli - $ gh repo view --web - `), - Annotations: map[string]string{ - "IsCore": "true", - "help:arguments": ` -A repository can be supplied as an argument in any of the following formats: -- "OWNER/REPO" -- by URL, e.g. "https://github.com/OWNER/REPO"`}, +func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "repo ", + Short: "Create, clone, fork, and view repositories", + Long: `Work with GitHub repositories`, + Example: heredoc.Doc(` + $ gh repo create + $ gh repo clone cli/cli + $ gh repo view --web + `), + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + A repository can be supplied as an argument in any of the following formats: + - "OWNER/REPO" + - by URL, e.g. "https://github.com/OWNER/REPO" + `), + }, + } + + cmd.AddCommand(repoViewCmd.NewCmdView(f, nil)) + cmd.AddCommand(repoForkCmd.NewCmdFork(f, nil)) + cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil)) + cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil)) + cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) + + return cmd } diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index f4741e9cd..a508317b0 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -4,7 +4,6 @@ import ( "fmt" "html/template" "net/http" - "net/url" "strings" "github.com/MakeNowJust/heredoc" @@ -71,22 +70,10 @@ func viewRun(opts *ViewOptions) error { return err } } else { - if utils.IsURL(opts.RepoArg) { - parsedURL, err := url.Parse(opts.RepoArg) - if err != nil { - return fmt.Errorf("did not understand argument: %w", err) - } - - toView, err = ghrepo.FromURL(parsedURL) - if err != nil { - return fmt.Errorf("did not understand argument: %w", err) - } - } else { - var err error - toView, err = ghrepo.FromFullName(opts.RepoArg) - if err != nil { - return fmt.Errorf("argument error: %w", err) - } + var err error + toView, err = ghrepo.FromFullName(opts.RepoArg) + if err != nil { + return fmt.Errorf("argument error: %w", err) } } diff --git a/pkg/cmd/root/completion.go b/pkg/cmd/root/completion.go new file mode 100644 index 000000000..ed907f6db --- /dev/null +++ b/pkg/cmd/root/completion.go @@ -0,0 +1,62 @@ +package root + +import ( + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { + var shellType string + + cmd := &cobra.Command{ + Use: "completion", + Short: "Generate shell completion scripts", + Long: heredoc.Doc(` + Generate shell completion scripts for GitHub CLI commands. + + The output of this command will be computer code and is meant to be saved to a + file or immediately evaluated by an interactive shell. + + For example, for bash you could add this to your '~/.bash_profile': + + eval "$(gh completion -s bash)" + + When installing GitHub CLI through a package manager, however, it's possible that + no additional shell configuration is necessary to gain completion support. For + Homebrew, see https://docs.brew.sh/Shell-Completion + `), + RunE: func(cmd *cobra.Command, args []string) error { + if shellType == "" { + if io.IsStdoutTTY() { + return &cmdutil.FlagError{Err: errors.New("error: the value for `--shell` is required")} + } + shellType = "bash" + } + + w := io.Out + rootCmd := cmd.Parent() + + switch shellType { + case "bash": + return rootCmd.GenBashCompletion(w) + case "zsh": + return rootCmd.GenZshCompletion(w) + case "powershell": + return rootCmd.GenPowerShellCompletion(w) + case "fish": + return rootCmd.GenFishCompletion(w, true) + default: + return fmt.Errorf("unsupported shell type %q", shellType) + } + }, + } + + cmd.Flags().StringVarP(&shellType, "shell", "s", "", "Shell type: {bash|zsh|fish|powershell}") + + return cmd +} diff --git a/pkg/cmd/root/completion_test.go b/pkg/cmd/root/completion_test.go new file mode 100644 index 000000000..505a65319 --- /dev/null +++ b/pkg/cmd/root/completion_test.go @@ -0,0 +1,79 @@ +package root + +import ( + "strings" + "testing" + + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/spf13/cobra" +) + +func TestNewCmdCompletion(t *testing.T) { + tests := []struct { + name string + args string + wantOut string + wantErr string + }{ + { + name: "no arguments", + args: "completion", + wantOut: "complete -o default -F __start_gh gh", + }, + { + name: "zsh completion", + args: "completion -s zsh", + wantOut: "#compdef _gh gh", + }, + { + name: "fish completion", + args: "completion -s fish", + wantOut: "complete -c gh ", + }, + { + name: "PowerShell completion", + args: "completion -s powershell", + wantOut: "Register-ArgumentCompleter", + }, + { + name: "unsupported shell", + args: "completion -s csh", + wantErr: "unsupported shell type \"csh\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + completeCmd := NewCmdCompletion(io) + rootCmd := &cobra.Command{Use: "gh"} + rootCmd.AddCommand(completeCmd) + + argv, err := shlex.Split(tt.args) + if err != nil { + t.Fatalf("argument splitting error: %v", err) + } + rootCmd.SetArgs(argv) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + _, err = rootCmd.ExecuteC() + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("expected error %q, got %q", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("error executing command: %v", err) + } + + if !strings.Contains(stdout.String(), tt.wantOut) { + t.Errorf("completion output did not match:\n%s", stdout.String()) + } + if len(stderr.String()) > 0 { + t.Errorf("expected nothing on stderr, got %q", stderr.String()) + } + }) + } +} diff --git a/command/help.go b/pkg/cmd/root/help.go similarity index 96% rename from command/help.go rename to pkg/cmd/root/help.go index d35c2602b..5570fbaaa 100644 --- a/command/help.go +++ b/pkg/cmd/root/help.go @@ -1,4 +1,4 @@ -package command +package root import ( "bytes" @@ -67,8 +67,12 @@ func nestedSuggestFunc(command *cobra.Command, arg string) { _ = rootUsageFunc(command) } +func isRootCmd(command *cobra.Command) bool { + return command != nil && !command.HasParent() +} + func rootHelpFunc(command *cobra.Command, args []string) { - if command.Parent() == RootCmd && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { + if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { nestedSuggestFunc(command, args[1]) hasFailed = true return @@ -141,7 +145,7 @@ Read the manual at https://cli.github.com/manual`}) helpEntries = append(helpEntries, helpEntry{"FEEDBACK", command.Annotations["help:feedback"]}) } - out := colorableOut(command) + out := command.OutOrStdout() for _, e := range helpEntries { if e.Title != "" { // If there is a title, add indentation to each line in the body diff --git a/command/help_test.go b/pkg/cmd/root/help_test.go similarity index 99% rename from command/help_test.go rename to pkg/cmd/root/help_test.go index e07542928..cbda48fe8 100644 --- a/command/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -1,4 +1,4 @@ -package command +package root import ( "testing" diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go new file mode 100644 index 000000000..39c95469d --- /dev/null +++ b/pkg/cmd/root/root.go @@ -0,0 +1,157 @@ +package root + +import ( + "fmt" + "regexp" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/ghrepo" + aliasCmd "github.com/cli/cli/pkg/cmd/alias" + apiCmd "github.com/cli/cli/pkg/cmd/api" + authCmd "github.com/cli/cli/pkg/cmd/auth" + configCmd "github.com/cli/cli/pkg/cmd/config" + gistCmd "github.com/cli/cli/pkg/cmd/gist" + issueCmd "github.com/cli/cli/pkg/cmd/issue" + prCmd "github.com/cli/cli/pkg/cmd/pr" + repoCmd "github.com/cli/cli/pkg/cmd/repo" + creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { + cmd := &cobra.Command{ + Use: "gh [flags]", + Short: "GitHub CLI", + Long: `Work seamlessly with GitHub from the command line.`, + + SilenceErrors: true, + SilenceUsage: true, + Example: heredoc.Doc(` + $ gh issue create + $ gh repo clone cli/cli + $ gh pr checkout 321 + `), + Annotations: map[string]string{ + "help:feedback": heredoc.Doc(` + Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7 + Open an issue using “gh issue create -R cli/cli” + `), + "help:environment": heredoc.Doc(` + GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being + prompted to authenticate and overrides any previously stored credentials. + + GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands + that otherwise operate on a local repository. + + GH_HOST: specify the GitHub hostname for commands that would otherwise assume + the "github.com" host when not in a context of an existing repository. + + GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use + for authoring text. + + BROWSER: the web browser to use for opening links. + + DEBUG: set to any value to enable verbose output to standard error. Include values "api" + or "oauth" to print detailed information about HTTP requests or authentication flow. + + GLAMOUR_STYLE: the style to use for rendering Markdown. See + https://github.com/charmbracelet/glamour#styles + + NO_COLOR: avoid printing ANSI escape sequences for color output. + `), + }, + } + + version = strings.TrimPrefix(version, "v") + if buildDate == "" { + cmd.Version = version + } else { + cmd.Version = fmt.Sprintf("%s (%s)", version, buildDate) + } + versionOutput := fmt.Sprintf("gh version %s\n%s\n", cmd.Version, changelogURL(version)) + cmd.AddCommand(&cobra.Command{ + Use: "version", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + fmt.Print(versionOutput) + }, + }) + cmd.SetVersionTemplate(versionOutput) + cmd.Flags().Bool("version", false, "Show gh version") + + cmd.SetOut(f.IOStreams.Out) + cmd.SetErr(f.IOStreams.ErrOut) + + cmd.PersistentFlags().Bool("help", false, "Show help for command") + cmd.SetHelpFunc(rootHelpFunc) + cmd.SetUsageFunc(rootUsageFunc) + + cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } + return &cmdutil.FlagError{Err: err} + }) + + // CHILD COMMANDS + + cmd.AddCommand(aliasCmd.NewCmdAlias(f)) + cmd.AddCommand(apiCmd.NewCmdApi(f, nil)) + cmd.AddCommand(authCmd.NewCmdAuth(f)) + cmd.AddCommand(configCmd.NewCmdConfig(f)) + cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) + cmd.AddCommand(gistCmd.NewCmdGist(f)) + cmd.AddCommand(NewCmdCompletion(f.IOStreams)) + + // below here at the commands that require the "intelligent" BaseRepo resolver + repoResolvingCmdFactory := *f + repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo(f) + + cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) + cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) + cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) + + return cmd +} + +func resolvedBaseRepo(f *cmdutil.Factory) func() (ghrepo.Interface, error) { + return func() (ghrepo.Interface, error) { + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + remotes, err := f.Remotes() + if err != nil { + return nil, err + } + repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + if err != nil { + return nil, err + } + baseRepo, err := repoContext.BaseRepo() + if err != nil { + return nil, err + } + + return baseRepo, nil + } +} + +func changelogURL(version string) string { + path := "https://github.com/cli/cli" + r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`) + if !r.MatchString(version) { + return fmt.Sprintf("%s/releases/latest", path) + } + + url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) + return url +} diff --git a/command/root_test.go b/pkg/cmd/root/root_test.go similarity index 98% rename from command/root_test.go rename to pkg/cmd/root/root_test.go index c0b1fd7d3..714032ec3 100644 --- a/command/root_test.go +++ b/pkg/cmd/root/root_test.go @@ -1,4 +1,4 @@ -package command +package root import ( "testing" diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go new file mode 100644 index 000000000..8b3d36489 --- /dev/null +++ b/pkg/cmdutil/repo_override.go @@ -0,0 +1,25 @@ +package cmdutil + +import ( + "os" + + "github.com/cli/cli/internal/ghrepo" + "github.com/spf13/cobra" +) + +func EnableRepoOverride(cmd *cobra.Command, f *Factory) { + cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `[HOST/]OWNER/REPO` format") + + cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + repoOverride, _ := cmd.Flags().GetString("repo") + if repoFromEnv := os.Getenv("GH_REPO"); repoOverride == "" && repoFromEnv != "" { + repoOverride = repoFromEnv + } + if repoOverride != "" { + // NOTE: this mutates the factory + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName(repoOverride) + } + } + } +} diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 0a25beac8..a1dcefaa3 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -43,7 +43,7 @@ func GraphQL(q string) Matcher { if !strings.EqualFold(req.Method, "POST") { return false } - if req.URL.Path != "/graphql" { + if req.URL.Path != "/graphql" && req.URL.Path != "/api/graphql" { return false } @@ -133,6 +133,19 @@ func GraphQLQuery(body string, cb func(string, map[string]interface{})) Responde } } +func ScopesResponder(scopes string) func(*http.Request) (*http.Response, error) { + return func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Request: req, + Header: map[string][]string{ + "X-Oauth-Scopes": {scopes}, + }, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + }, nil + } +} + func httpResponse(status int, req *http.Request, body io.Reader) *http.Response { return &http.Response{ StatusCode: status, diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index a1ffdad32..cf69ecd8c 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -2,12 +2,17 @@ package iostreams import ( "bytes" + "fmt" "io" "io/ioutil" "os" + "os/exec" + "strconv" + "strings" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" + "golang.org/x/crypto/ssh/terminal" ) type IOStreams struct { @@ -74,6 +79,29 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) TerminalWidth() int { + defaultWidth := 80 + if s.stdoutTTYOverride { + return defaultWidth + } + + if w, _, err := terminalSize(s.Out); err == nil { + return w + } + + if isCygwinTerminal(s.Out) { + tputCmd := exec.Command("tput", "cols") + tputCmd.Stdin = os.Stdin + if out, err := tputCmd.Output(); err == nil { + if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + return w + } + } + } + + return defaultWidth +} + func System() *IOStreams { var out io.Writer = os.Stdout var colorEnabled bool @@ -104,3 +132,17 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { func isTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } + +func isCygwinTerminal(w io.Writer) bool { + if f, isFile := w.(*os.File); isFile { + return isatty.IsCygwinTerminal(f.Fd()) + } + return false +} + +func terminalSize(w io.Writer) (int, int, error) { + if f, isFile := w.(*os.File); isFile { + return terminal.GetSize(int(f.Fd())) + } + return 0, 0, fmt.Errorf("%v is not a file", w) +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 05683640e..169b0f42c 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -21,6 +21,10 @@ var Confirm = func(prompt string, result *bool) error { return survey.AskOne(p, result) } +var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + return survey.AskOne(p, response, opts...) +} + var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { return survey.Ask(qs, response, opts...) } diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go index e988962d5..77d37b350 100644 --- a/pkg/prompt/stubber.go +++ b/pkg/prompt/stubber.go @@ -8,15 +8,38 @@ import ( "github.com/AlecAivazis/survey/v2/core" ) -type askStubber struct { - Asks [][]*survey.Question - Count int - Stubs [][]*QuestionStub +type AskStubber struct { + Asks [][]*survey.Question + AskOnes []*survey.Prompt + Count int + OneCount int + Stubs [][]*QuestionStub + StubOnes []*PromptStub } -func InitAskStubber() (*askStubber, func()) { +func InitAskStubber() (*AskStubber, func()) { origSurveyAsk := SurveyAsk - as := askStubber{} + origSurveyAskOne := SurveyAskOne + as := AskStubber{} + + SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + as.AskOnes = append(as.AskOnes, &p) + count := as.OneCount + as.OneCount += 1 + if count > len(as.StubOnes) { + panic(fmt.Sprintf("more asks than stubs. most recent call: %v", p)) + } + stubbedPrompt := as.StubOnes[count] + if stubbedPrompt.Default { + defaultValue := reflect.ValueOf(p).Elem().FieldByName("Default") + _ = core.WriteAnswer(response, "", defaultValue) + } else { + _ = core.WriteAnswer(response, "", stubbedPrompt.Value) + } + + return nil + } + SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { as.Asks = append(as.Asks, qs) count := as.Count @@ -44,17 +67,35 @@ func InitAskStubber() (*askStubber, func()) { } teardown := func() { SurveyAsk = origSurveyAsk + SurveyAskOne = origSurveyAskOne } return &as, teardown } +type PromptStub struct { + Value interface{} + Default bool +} + type QuestionStub struct { Name string Value interface{} Default bool } -func (as *askStubber) Stub(stubbedQuestions []*QuestionStub) { +func (as *AskStubber) StubOne(value interface{}) { + as.StubOnes = append(as.StubOnes, &PromptStub{ + Value: value, + }) +} + +func (as *AskStubber) StubOneDefault() { + as.StubOnes = append(as.StubOnes, &PromptStub{ + Default: true, + }) +} + +func (as *AskStubber) Stub(stubbedQuestions []*QuestionStub) { // A call to .Ask takes a list of questions; a stub is then a list of questions in the same order. as.Stubs = append(as.Stubs, stubbedQuestions) } diff --git a/pkg/text/sanitize.go b/pkg/text/sanitize.go new file mode 100644 index 000000000..16bb902dc --- /dev/null +++ b/pkg/text/sanitize.go @@ -0,0 +1,12 @@ +package text + +import ( + "regexp" + "strings" +) + +var ws = regexp.MustCompile(`\s+`) + +func ReplaceExcessiveWhitespace(s string) string { + return ws.ReplaceAllString(strings.TrimSpace(s), " ") +} diff --git a/pkg/text/sanitize_test.go b/pkg/text/sanitize_test.go new file mode 100644 index 000000000..1c03362d9 --- /dev/null +++ b/pkg/text/sanitize_test.go @@ -0,0 +1,29 @@ +package text + +import "testing" + +func TestReplaceExcessiveWhitespace(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no replacements", + input: "one two three", + want: "one two three", + }, + { + name: "whitespace b-gone", + input: "\n one\n\t two three\r\n ", + want: "one two three", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ReplaceExcessiveWhitespace(tt.input); got != tt.want { + t.Errorf("ReplaceExcessiveWhitespace() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/test/fixtures/prStatusFork.json b/test/fixtures/prStatusFork.json deleted file mode 100644 index c9a7a5b3a..000000000 --- a/test/fixtures/prStatusFork.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "totalCount": 1, - "edges": [ - { - "node": { - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "url": "https://github.com/PARENT/REPO/pull/10", - "headRefName": "blueberries", - "isDraft": false, - "headRepositoryOwner": { - "login": "OWNER" - }, - "isCrossRepository": true - } - } - ] - } - }, - "viewerCreated": { - "totalCount": 0, - "edges": [] - }, - "reviewRequested": { - "totalCount": 0, - "edges": [] - } - } -} diff --git a/test/helpers.go b/test/helpers.go index b10e87a85..edfa25bda 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -6,7 +6,6 @@ import ( "fmt" "os/exec" "regexp" - "testing" "github.com/cli/cli/internal/run" ) @@ -86,7 +85,13 @@ func createStubbedPrepareCmd(cs *CmdStubber) func(*exec.Cmd) run.Runnable { } } -func ExpectLines(t *testing.T, output string, lines ...string) { +type T interface { + Helper() + Errorf(string, ...interface{}) +} + +func ExpectLines(t T, output string, lines ...string) { + t.Helper() var r *regexp.Regexp for _, l := range lines { r = regexp.MustCompile(l) diff --git a/utils/table_printer.go b/utils/table_printer.go index 823a4b533..3047e2d81 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -3,11 +3,9 @@ package utils import ( "fmt" "io" - "os" - "os/exec" - "strconv" "strings" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/text" ) @@ -18,28 +16,15 @@ type TablePrinter interface { Render() error } -func NewTablePrinter(w io.Writer) TablePrinter { - if IsTerminal(w) { - isCygwin := IsCygwinTerminal(w) - ttyWidth := 80 - if termWidth, _, err := TerminalSize(w); err == nil { - ttyWidth = termWidth - } else if isCygwin { - tputCmd := exec.Command("tput", "cols") - tputCmd.Stdin = os.Stdin - if out, err := tputCmd.Output(); err == nil { - if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { - ttyWidth = w - } - } - } +func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { + if io.IsStdoutTTY() { return &ttyTablePrinter{ - out: NewColorable(w), - maxWidth: ttyWidth, + out: io.Out, + maxWidth: io.TerminalWidth(), } } return &tsvTablePrinter{ - out: w, + out: io.Out, } } diff --git a/utils/utils.go b/utils/utils.go index 20a5e55a8..cee56bf58 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -114,3 +114,7 @@ func DisplayURL(urlStr string) string { } return u.Hostname() + u.Path } + +func GreenCheck() string { + return Green("✓") +}