Merge branch 'trunk' of https://github.com/cli/cli into newConfirmations
This commit is contained in:
commit
2b6535f951
159 changed files with 11154 additions and 7245 deletions
4
.github/workflows/go.yml
vendored
4
.github/workflows/go.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/releases.yml
vendored
4
.github/workflows/releases.yml
vendored
|
|
@ -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/}
|
||||
|
|
|
|||
32
README.md
32
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
233
command/alias.go
233
command/alias.go
|
|
@ -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 <alias> <expansion>",
|
||||
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 <alias>",
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <key>",
|
||||
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 <key> <value>",
|
||||
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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
838
command/issue.go
838
command/issue.go
|
|
@ -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 <command>",
|
||||
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 {<number> | <url>}",
|
||||
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 {<number> | <url>}",
|
||||
Short: "Close issue",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: issueClose,
|
||||
}
|
||||
var issueReopenCmd = &cobra.Command{
|
||||
Use: "reopen {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1044
command/pr.go
1044
command/pr.go
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
1731
command/pr_test.go
1731
command/pr_test.go
File diff suppressed because it is too large
Load diff
482
command/root.go
482
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 <command> <subcommand> [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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
pkg/cmd/alias/alias.go
Normal file
28
pkg/cmd/alias/alias.go
Normal file
|
|
@ -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
|
||||
}
|
||||
71
pkg/cmd/alias/delete/delete.go
Normal file
71
pkg/cmd/alias/delete/delete.go
Normal file
|
|
@ -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 <alias>",
|
||||
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
|
||||
}
|
||||
90
pkg/cmd/alias/delete/delete_test.go
Normal file
90
pkg/cmd/alias/delete/delete_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
106
pkg/cmd/alias/expand/expand.go
Normal file
106
pkg/cmd/alias/expand/expand.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
185
pkg/cmd/alias/expand/expand_test.go
Normal file
185
pkg/cmd/alias/expand/expand_test.go
Normal file
|
|
@ -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{}, ""},
|
||||
// } {
|
||||
83
pkg/cmd/alias/list/list.go
Normal file
83
pkg/cmd/alias/list/list.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
77
pkg/cmd/alias/list/list_test.go
Normal file
77
pkg/cmd/alias/list/list_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
147
pkg/cmd/alias/set/set.go
Normal file
147
pkg/cmd/alias/set/set.go
Normal file
|
|
@ -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 <alias> <expansion>",
|
||||
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
|
||||
}
|
||||
252
pkg/cmd/alias/set/set_test.go
Normal file
252
pkg/cmd/alias/set/set_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{}:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
pkg/cmd/auth/auth.go
Normal file
25
pkg/cmd/auth/auth.go
Normal file
|
|
@ -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 <command>",
|
||||
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
|
||||
}
|
||||
48
pkg/cmd/auth/client/client.go
Normal file
48
pkg/cmd/auth/client/client.go
Normal file
|
|
@ -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
|
||||
}
|
||||
290
pkg/cmd/auth/login/login.go
Normal file
290
pkg/cmd/auth/login/login.go
Normal file
|
|
@ -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
|
||||
}
|
||||
423
pkg/cmd/auth/login/login_test.go
Normal file
423
pkg/cmd/auth/login/login_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
162
pkg/cmd/auth/logout/logout.go
Normal file
162
pkg/cmd/auth/logout/logout.go
Normal file
|
|
@ -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
|
||||
}
|
||||
272
pkg/cmd/auth/logout/logout_test.go
Normal file
272
pkg/cmd/auth/logout/logout_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
116
pkg/cmd/auth/refresh/refresh.go
Normal file
116
pkg/cmd/auth/refresh/refresh.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
244
pkg/cmd/auth/refresh/refresh_test.go
Normal file
244
pkg/cmd/auth/refresh/refresh_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
199
pkg/cmd/auth/status/status.go
Normal file
199
pkg/cmd/auth/status/status.go
Normal file
|
|
@ -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
|
||||
}
|
||||
299
pkg/cmd/auth/status/status_test.go
Normal file
299
pkg/cmd/auth/status/status_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
98
pkg/cmd/config/config.go
Normal file
98
pkg/cmd/config/config.go
Normal file
|
|
@ -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 <key>",
|
||||
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 <key> <value>",
|
||||
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
|
||||
}
|
||||
156
pkg/cmd/config/config_test.go
Normal file
156
pkg/cmd/config/config_test.go
Normal file
|
|
@ -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"])
|
||||
})
|
||||
}
|
||||
}
|
||||
67
pkg/cmd/factory/default.go
Normal file
67
pkg/cmd/factory/default.go
Normal file
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
68
pkg/cmd/factory/http.go
Normal file
68
pkg/cmd/factory/http.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
80
pkg/cmd/factory/remote_resolver.go
Normal file
80
pkg/cmd/factory/remote_resolver.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
42
pkg/cmd/factory/remote_resolver_test.go
Normal file
42
pkg/cmd/factory/remote_resolver_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
19
pkg/cmd/gist/gist.go
Normal file
19
pkg/cmd/gist/gist.go
Normal file
|
|
@ -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
|
||||
}
|
||||
80
pkg/cmd/issue/close/close.go
Normal file
80
pkg/cmd/issue/close/close.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
119
pkg/cmd/issue/close/close_test.go
Normal file
119
pkg/cmd/issue/close/close_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
228
pkg/cmd/issue/create/create.go
Normal file
228
pkg/cmd/issue/create/create.go
Normal file
|
|
@ -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
|
||||
}
|
||||
280
pkg/cmd/issue/create/create_test.go
Normal file
280
pkg/cmd/issue/create/create_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
45
pkg/cmd/issue/issue.go
Normal file
45
pkg/cmd/issue/issue.go
Normal file
|
|
@ -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 <command>",
|
||||
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
|
||||
}
|
||||
127
pkg/cmd/issue/list/list.go
Normal file
127
pkg/cmd/issue/list/list.go
Normal file
|
|
@ -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
|
||||
}
|
||||
223
pkg/cmd/issue/list/list_test.go
Normal file
223
pkg/cmd/issue/list/list_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
80
pkg/cmd/issue/reopen/reopen.go
Normal file
80
pkg/cmd/issue/reopen/reopen.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
65
pkg/cmd/issue/shared/display.go
Normal file
65
pkg/cmd/issue/shared/display.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
103
pkg/cmd/issue/status/status.go
Normal file
103
pkg/cmd/issue/status/status.go
Normal file
|
|
@ -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
|
||||
}
|
||||
146
pkg/cmd/issue/status/status_test.go
Normal file
146
pkg/cmd/issue/status/status_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
207
pkg/cmd/issue/view/view.go
Normal file
207
pkg/cmd/issue/view/view.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
340
pkg/cmd/issue/view/view_test.go
Normal file
340
pkg/cmd/issue/view/view_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
83
pkg/cmd/pr/close/close.go
Normal file
83
pkg/cmd/pr/close/close.go
Normal file
|
|
@ -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 {<number> | <url> | <branch>}",
|
||||
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
|
||||
}
|
||||
101
pkg/cmd/pr/close/close_test.go
Normal file
101
pkg/cmd/pr/close/close_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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`")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
170
pkg/cmd/pr/list/list.go
Normal file
170
pkg/cmd/pr/list/list.go
Normal file
|
|
@ -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
|
||||
}
|
||||
246
pkg/cmd/pr/list/list_test.go
Normal file
246
pkg/cmd/pr/list/list_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
274
pkg/cmd/pr/merge/merge.go
Normal file
274
pkg/cmd/pr/merge/merge.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue