Merge branch 'gh-org-command' of https://github.com/joshkraft/cli into gh-org-command
This commit is contained in:
commit
4d20db0ca0
49 changed files with 1142 additions and 138 deletions
|
|
@ -70,7 +70,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md).
|
|||
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
|
||||
|
||||
> **Note**
|
||||
> The Windows installer modifes your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
|
||||
> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
|
||||
|
||||
#### scoop
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ sudo dnf update gh
|
|||
Install using our package repository for immediate access to latest releases:
|
||||
|
||||
```bash
|
||||
type -p yum-config-manager >/dev/null || sudo yum install yum-utils
|
||||
sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
|
||||
sudo yum install gh
|
||||
```
|
||||
|
|
|
|||
|
|
@ -354,6 +354,22 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
|
|||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Client) TrackingBranchNames(ctx context.Context, prefix string) []string {
|
||||
args := []string{"branch", "-r", "--format", "%(refname:strip=3)"}
|
||||
if prefix != "" {
|
||||
args = append(args, "--list", fmt.Sprintf("*/%s*", escapeGlob(prefix)))
|
||||
}
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(output), "\n")
|
||||
}
|
||||
|
||||
// ToplevelDir returns the top-level directory path of the current repository.
|
||||
func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
|
||||
out, err := c.revParse(ctx, "--show-toplevel")
|
||||
|
|
@ -623,3 +639,16 @@ func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var globReplacer = strings.NewReplacer(
|
||||
"*", `\*`,
|
||||
"?", `\?`,
|
||||
"[", `\[`,
|
||||
"]", `\]`,
|
||||
"{", `\{`,
|
||||
"}", `\}`,
|
||||
)
|
||||
|
||||
func escapeGlob(p string) string {
|
||||
return globReplacer.Replace(p)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ type LoginOptions struct {
|
|||
|
||||
Interactive bool
|
||||
|
||||
Hostname string
|
||||
Scopes []string
|
||||
Token string
|
||||
Web bool
|
||||
GitProtocol string
|
||||
SecureStorage bool
|
||||
Hostname string
|
||||
Scopes []string
|
||||
Token string
|
||||
Web bool
|
||||
GitProtocol string
|
||||
InsecureStorage bool
|
||||
}
|
||||
|
||||
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
|
|
@ -124,7 +124,13 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
|
|||
cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input")
|
||||
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations")
|
||||
cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Save authentication credentials in secure credential store")
|
||||
|
||||
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
|
||||
var secureStorage bool
|
||||
cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store")
|
||||
_ = cmd.Flags().MarkHidden("secure-storage")
|
||||
|
||||
cmd.Flags().BoolVar(&opts.InsecureStorage, "insecure-storage", false, "Save authentication credentials in plain text instead of credential store")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -136,11 +142,6 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
authCfg := cfg.Authentication()
|
||||
|
||||
if opts.SecureStorage {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Using secure storage could break installed extensions\n", cs.WarningIcon())
|
||||
}
|
||||
|
||||
hostname := opts.Hostname
|
||||
if opts.Interactive && hostname == "" {
|
||||
var err error
|
||||
|
|
@ -166,7 +167,7 @@ func loginRun(opts *LoginOptions) error {
|
|||
return fmt.Errorf("error validating token: %w", err)
|
||||
}
|
||||
// Adding a user key ensures that a nonempty host section gets written to the config file.
|
||||
return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, opts.SecureStorage)
|
||||
return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, !opts.InsecureStorage)
|
||||
}
|
||||
|
||||
existingToken, _ := authCfg.Token(hostname)
|
||||
|
|
@ -195,7 +196,7 @@ func loginRun(opts *LoginOptions) error {
|
|||
Prompter: opts.Prompter,
|
||||
GitClient: opts.GitClient,
|
||||
Browser: opts.Browser,
|
||||
SecureStorage: opts.SecureStorage,
|
||||
SecureStorage: !opts.InsecureStorage,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,16 +178,31 @@ func Test_NewCmdLogin(t *testing.T) {
|
|||
stdinTTY: true,
|
||||
cli: "--secure-storage",
|
||||
wants: LoginOptions{
|
||||
Interactive: true,
|
||||
SecureStorage: true,
|
||||
Interactive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nontty secure-storage",
|
||||
cli: "--secure-storage",
|
||||
wants: LoginOptions{
|
||||
Hostname: "github.com",
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tty insecure-storage",
|
||||
stdinTTY: true,
|
||||
cli: "--insecure-storage",
|
||||
wants: LoginOptions{
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nontty insecure-storage",
|
||||
cli: "--insecure-storage",
|
||||
wants: LoginOptions{
|
||||
Hostname: "github.com",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -251,10 +266,11 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
wantSecureToken string
|
||||
}{
|
||||
{
|
||||
name: "with token",
|
||||
name: "insecure with token",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -262,11 +278,12 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
wantHosts: "github.com:\n oauth_token: abc123\n user: x-access-token\n",
|
||||
},
|
||||
{
|
||||
name: "with token and https git-protocol",
|
||||
name: "insecure with token and https git-protocol",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
GitProtocol: "https",
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
GitProtocol: "https",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -276,8 +293,9 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "with token and non-default host",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "albert.wesker",
|
||||
Token: "abc123",
|
||||
Hostname: "albert.wesker",
|
||||
Token: "abc123",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -309,8 +327,9 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "has admin scope",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc456",
|
||||
Hostname: "github.com",
|
||||
Token: "abc456",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org"))
|
||||
|
|
@ -358,16 +377,14 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "with token and secure storage",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
},
|
||||
wantHosts: "github.com:\n user: x-access-token\n",
|
||||
wantSecureToken: "abc123",
|
||||
wantStderr: "! Using secure storage could break installed extensions\n",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -463,8 +480,9 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
{
|
||||
name: "hostname set",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "rebecca.chambers",
|
||||
Interactive: true,
|
||||
Hostname: "rebecca.chambers",
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
wantHosts: heredoc.Doc(`
|
||||
rebecca.chambers:
|
||||
|
|
@ -504,7 +522,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: https
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -543,7 +562,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: https
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -573,7 +593,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: ssh
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -593,9 +614,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
{
|
||||
name: "secure storage",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Interactive: true,
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
Interactive: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -617,7 +637,7 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
user: jillv
|
||||
git_protocol: https
|
||||
`),
|
||||
wantErrOut: regexp.MustCompile("! Using secure storage could break installed extensions"),
|
||||
wantErrOut: regexp.MustCompile("Logged in as jillv"),
|
||||
wantSecureToken: "def456",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -29,8 +30,26 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr
|
|||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Short: "Configure git to use GitHub CLI as a credential helper",
|
||||
Use: "setup-git",
|
||||
Short: "Setup git with GitHub CLI",
|
||||
Long: heredoc.Docf(`
|
||||
This command configures git to use GitHub CLI as a credential helper.
|
||||
For more information on git credential helpers please reference:
|
||||
https://git-scm.com/docs/gitcredentials.
|
||||
|
||||
By default, GitHub CLI will be set as the credential helper for all authenticated hosts.
|
||||
If there is no authenticated hosts the command fails with an error.
|
||||
|
||||
Alternatively, use the %[1]s--hostname%[1]s flag to specify a single host to be configured.
|
||||
If the host is not authenticated with, the command fails with an error.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# Configure git to use GitHub CLI as the credential helper for all authenticated hosts
|
||||
$ gh auth setup-git
|
||||
|
||||
# Configure git to use GitHub CLI as the credential helper for enterprise.internal host
|
||||
$ gh auth setup-git --hostname enterprise.internal
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.gitConfigure = &shared.GitCredentialFlow{
|
||||
Executable: f.Executable(),
|
||||
|
|
|
|||
|
|
@ -200,11 +200,16 @@ func Login(opts *LoginOptions) error {
|
|||
}
|
||||
|
||||
if keyToUpload != "" {
|
||||
err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle)
|
||||
uploaded, err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
|
||||
if uploaded {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s SSH key already existed on your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
|
||||
|
|
@ -223,10 +228,10 @@ func scopesSentence(scopes []string, isEnterprise bool) string {
|
|||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) error {
|
||||
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) (bool, error) {
|
||||
f, err := os.Open(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ func TestLogin_ssh(t *testing.T) {
|
|||
tr.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{ "login": "monalisa" }}}`))
|
||||
tr.Register(
|
||||
httpmock.REST("GET", "api/v3/user/keys"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
tr.Register(
|
||||
httpmock.REST("POST", "api/v3/user/keys"),
|
||||
httpmock.StringResponse(`{}`))
|
||||
|
|
@ -78,7 +81,7 @@ func TestLogin_ssh(t *testing.T) {
|
|||
}
|
||||
assert.Equal(t, expected, args)
|
||||
// simulate that the public key file has been generated
|
||||
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY"), 0600)
|
||||
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY asdf"), 0600)
|
||||
})
|
||||
|
||||
cfg := tinyConfig{}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm
|
|||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance authenticated with")
|
||||
cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Search only secure credential store for authentication token")
|
||||
_ = cmd.Flags().MarkHidden("secure-storeage")
|
||||
_ = cmd.Flags().MarkHidden("secure-storage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd := &cobra.Command{
|
||||
Long: "Open the GitHub repository in the web browser.",
|
||||
Short: "Open the repository in the browser",
|
||||
Use: "browse [<number> | <path>]",
|
||||
Use: "browse [<number> | <path> | <commit-SHA>]",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Example: heredoc.Doc(`
|
||||
$ gh browse
|
||||
|
|
@ -87,7 +87,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
"help:arguments": heredoc.Doc(`
|
||||
A browser location can be specified using arguments in the following format:
|
||||
- by number for issue or pull request, e.g. "123"; or
|
||||
- by path for opening folders and files, e.g. "cmd/gh/main.go"
|
||||
- by path for opening folders and files, e.g. "cmd/gh/main.go"; or
|
||||
- by commit SHA
|
||||
`),
|
||||
"help:environment": heredoc.Doc(`
|
||||
To configure a web browser other than the default, use the BROWSER environment variable.
|
||||
|
|
@ -124,6 +125,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
return err
|
||||
}
|
||||
|
||||
if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") {
|
||||
return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg)
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("repo") {
|
||||
opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient}
|
||||
}
|
||||
|
|
@ -144,6 +149,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
// Preserve backwards compatibility for when commit flag used to be a boolean flag.
|
||||
cmd.Flags().Lookup("commit").NoOptDefVal = emptyCommitFlag
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,27 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "passed both branch and commit flags",
|
||||
cli: "main.go --branch main --comit=12a4",
|
||||
cli: "main.go --branch main --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both number arg and branch flag",
|
||||
cli: "1 --branch trunk",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both number arg and commit flag",
|
||||
cli: "1 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both commit SHA arg and branch flag",
|
||||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --branch trunk",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both commit SHA arg and commit flag",
|
||||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ type CreateOptions struct {
|
|||
Labels []string
|
||||
Projects []string
|
||||
Milestone string
|
||||
Template string
|
||||
}
|
||||
|
||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||
|
|
@ -91,6 +93,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
return cmdutil.FlagErrorf("`--recover` only supported when running interactively")
|
||||
}
|
||||
|
||||
if opts.Template != "" && bodyProvided {
|
||||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||
}
|
||||
|
||||
opts.Interactive = !(titleProvided && bodyProvided)
|
||||
|
||||
if opts.Interactive && !opts.IO.CanPrompt() {
|
||||
|
|
@ -113,6 +119,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
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`")
|
||||
cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
|
||||
cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -216,9 +223,17 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
if opts.RecoverFile == "" {
|
||||
var template shared.Template
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
if opts.Template != "" {
|
||||
template, err = tpl.Select(opts.Template)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if template != nil {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,38 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from name tty",
|
||||
tty: true,
|
||||
cli: `-t mytitle --template "bug report"`,
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "mytitle",
|
||||
Body: "",
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
Template: "bug report",
|
||||
Interactive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from name non-tty",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report" --body "issue body"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body file",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -134,6 +166,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile)
|
||||
assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode)
|
||||
assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive)
|
||||
assert.Equal(t, tt.wantsOpts.Template, opts.Template)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type ChecksOptions struct {
|
|||
WebMode bool
|
||||
Interval time.Duration
|
||||
Watch bool
|
||||
FailFast bool
|
||||
Required bool
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +60,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
|
|||
return cmdutil.FlagErrorf("argument required when using the `--repo` flag")
|
||||
}
|
||||
|
||||
if opts.FailFast && !opts.Watch {
|
||||
return cmdutil.FlagErrorf("cannot use `--fail-fast` flag without `--watch` flag")
|
||||
}
|
||||
|
||||
intervalChanged := cmd.Flags().Changed("interval")
|
||||
if !opts.Watch && intervalChanged {
|
||||
return cmdutil.FlagErrorf("cannot use `--interval` flag without `--watch` flag")
|
||||
|
|
@ -86,6 +91,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
|
|||
|
||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks")
|
||||
cmd.Flags().BoolVarP(&opts.Watch, "watch", "", false, "Watch checks until they finish")
|
||||
cmd.Flags().BoolVarP(&opts.FailFast, "fail-fast", "", false, "Exit watch mode on first check failure")
|
||||
cmd.Flags().IntVarP(&interval, "interval", "i", 10, "Refresh interval in seconds when using `--watch` flag")
|
||||
cmd.Flags().BoolVar(&opts.Required, "required", false, "Only show checks that are required")
|
||||
|
||||
|
|
@ -171,6 +177,10 @@ func checksRun(opts *ChecksOptions) error {
|
|||
break
|
||||
}
|
||||
|
||||
if opts.FailFast && counts.Failed > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(opts.Interval)
|
||||
|
||||
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,20 @@ func TestNewCmdChecks(t *testing.T) {
|
|||
cli: "--interval 5",
|
||||
wantsError: "cannot use `--interval` flag without `--watch` flag",
|
||||
},
|
||||
{
|
||||
name: "watch with fail-fast flag",
|
||||
cli: "--watch --fail-fast",
|
||||
wants: ChecksOptions{
|
||||
Watch: true,
|
||||
FailFast: true,
|
||||
Interval: time.Duration(10000000000),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail-fast flag without watch flag",
|
||||
cli: "--fail-fast",
|
||||
wantsError: "cannot use `--fail-fast` flag without `--watch` flag",
|
||||
},
|
||||
{
|
||||
name: "required flag",
|
||||
cli: "--required",
|
||||
|
|
@ -102,6 +116,7 @@ func TestNewCmdChecks(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.Watch, gotOpts.Watch)
|
||||
assert.Equal(t, tt.wants.Interval, gotOpts.Interval)
|
||||
assert.Equal(t, tt.wants.Required, gotOpts.Required)
|
||||
assert.Equal(t, tt.wants.FailFast, gotOpts.FailFast)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +126,7 @@ func Test_checksRun(t *testing.T) {
|
|||
name string
|
||||
tty bool
|
||||
watch bool
|
||||
failFast bool
|
||||
required bool
|
||||
httpStubs func(*httpmock.Registry)
|
||||
wantOut string
|
||||
|
|
@ -189,6 +205,20 @@ func Test_checksRun(t *testing.T) {
|
|||
wantOut: "\x1b[?1049hAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n\x1b[?1049lAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "watch some failing with fail fast",
|
||||
tty: true,
|
||||
watch: true,
|
||||
failFast: true,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
|
||||
httpmock.FileResponse("./fixtures/someFailing.json"),
|
||||
)
|
||||
},
|
||||
wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing checks status every 0 seconds. Press Ctrl+C to quit.\n\nSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n\x1b[?1049lSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n",
|
||||
wantErr: "SilentError",
|
||||
},
|
||||
{
|
||||
name: "with statuses",
|
||||
tty: true,
|
||||
|
|
@ -316,6 +346,7 @@ func Test_checksRun(t *testing.T) {
|
|||
SelectorArg: "123",
|
||||
Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")),
|
||||
Watch: tt.watch,
|
||||
FailFast: tt.failFast,
|
||||
Required: tt.required,
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +412,7 @@ func TestChecksRun_web(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEliminateDupulicates(t *testing.T) {
|
||||
func TestEliminateDuplicates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checkContexts []api.CheckContext
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type CreateOptions struct {
|
|||
Milestone string
|
||||
|
||||
MaintainerCanModify bool
|
||||
Template string
|
||||
}
|
||||
|
||||
type CreateContext struct {
|
||||
|
|
@ -155,6 +156,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
opts.BodyProvided = true
|
||||
}
|
||||
|
||||
if opts.Template != "" && opts.BodyProvided {
|
||||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||
}
|
||||
|
||||
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.Autofill && (!opts.TitleProvided || !opts.BodyProvided) {
|
||||
return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill`) when not running interactively")
|
||||
}
|
||||
|
|
@ -182,6 +187,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`")
|
||||
fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request")
|
||||
fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
|
||||
fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
results, err := requestableReviewersForCompletion(opts)
|
||||
|
|
@ -295,9 +303,17 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
if opts.RecoverFile == "" {
|
||||
tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
|
||||
var template shared.Template
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
if opts.Template != "" {
|
||||
template, err = tpl.Select(opts.Template)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if template != nil {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,44 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
MaintainerCanModify: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from file name tty",
|
||||
tty: true,
|
||||
cli: "-t mytitle --template bug_fix.md",
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "mytitle",
|
||||
TitleProvided: true,
|
||||
Body: "",
|
||||
BodyProvided: false,
|
||||
Autofill: false,
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
IsDraft: false,
|
||||
BaseBranch: "",
|
||||
HeadBranch: "",
|
||||
MaintainerCanModify: true,
|
||||
Template: "bug_fix.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from file name non-tty",
|
||||
tty: false,
|
||||
cli: "-t mytitle --template bug_fix.md",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template bug_fix.md --body "pr body"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body file",
|
||||
tty: false,
|
||||
cli: "-t mytitle --template bug_fix.md --body-file body_file.md",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -178,6 +216,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
assert.Equal(t, tt.wantsOpts.MaintainerCanModify, opts.MaintainerCanModify)
|
||||
assert.Equal(t, tt.wantsOpts.BaseBranch, opts.BaseBranch)
|
||||
assert.Equal(t, tt.wantsOpts.HeadBranch, opts.HeadBranch)
|
||||
assert.Equal(t, tt.wantsOpts.Template, opts.Template)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`")
|
||||
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base")
|
||||
|
||||
for _, flagName := range []string{"add-reviewer", "remove-reviewer"} {
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
baseRepo, err := f.BaseRepo()
|
||||
|
|
|
|||
|
|
@ -109,9 +109,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee")
|
||||
cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`")
|
||||
cmdutil.NilBoolFlag(cmd, &opts.Draft, "draft", "d", "Filter by draft state")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package shared
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -199,6 +200,24 @@ func (m *templateManager) Choose() (Template, error) {
|
|||
return m.templates[selectedOption], nil
|
||||
}
|
||||
|
||||
func (m *templateManager) Select(name string) (Template, error) {
|
||||
if err := m.memoizedFetch(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(m.templates) == 0 {
|
||||
return nil, errors.New("no templates found")
|
||||
}
|
||||
|
||||
for _, t := range m.templates {
|
||||
if t.Name() == name {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
|
||||
func (m *templateManager) memoizedFetch() error {
|
||||
if m.didFetch {
|
||||
return m.fetchError
|
||||
|
|
|
|||
|
|
@ -113,3 +113,111 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
|||
assert.Equal(t, "", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
||||
}
|
||||
|
||||
func TestTemplateManagerSelect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isPR bool
|
||||
templateName string
|
||||
wantTemplate Template
|
||||
wantErr bool
|
||||
errMsg string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
}{
|
||||
{
|
||||
name: "no templates found",
|
||||
templateName: "Bug report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"issueTemplates":[]}}}`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "no templates found",
|
||||
},
|
||||
{
|
||||
name: "no matching templates found",
|
||||
templateName: "Unknown report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issueTemplates": [
|
||||
{ "name": "Bug report", "body": "I found a problem" },
|
||||
{ "name": "Feature request", "body": "I need a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: `template "Unknown report" not found`,
|
||||
},
|
||||
{
|
||||
name: "matching issue template found",
|
||||
templateName: "Bug report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issueTemplates": [
|
||||
{ "name": "Bug report", "body": "I found a problem" },
|
||||
{ "name": "Feature request", "body": "I need a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantTemplate: &issueTemplate{
|
||||
Gname: "Bug report",
|
||||
Gbody: "I found a problem",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching pull request template found",
|
||||
isPR: true,
|
||||
templateName: "feature.md",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "PullRequestTemplates": [
|
||||
{ "filename": "bug.md", "body": "I fixed a problem" },
|
||||
{ "filename": "feature.md", "body": "I made a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantTemplate: &pullRequestTemplate{
|
||||
Gname: "feature.md",
|
||||
Gbody: "I made a feature",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
|
||||
m := templateManager{
|
||||
repo: ghrepo.NewWithHost("OWNER", "REPO", "example.com"),
|
||||
allowFS: false,
|
||||
isPR: tt.isPR,
|
||||
httpClient: &http.Client{Transport: reg},
|
||||
detector: &fd.EnabledDetectorMock{},
|
||||
}
|
||||
|
||||
tmpl, err := m.Select(tt.templateName)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if tt.wantTemplate != nil {
|
||||
assert.Equal(t, tt.wantTemplate.Name(), tmpl.Name())
|
||||
assert.Equal(t, tt.wantTemplate.Body(), tmpl.Body())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default: automatic based on date and version)")
|
||||
cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVar(&opts.TagName, "tag", "", "The name of the tag")
|
||||
cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
|
|
@ -21,6 +25,10 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type errWithExitCode interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
||||
type iprompter interface {
|
||||
Input(string, string) (string, error)
|
||||
Select(string, string, []string) (int, error)
|
||||
|
|
@ -33,6 +41,7 @@ type CreateOptions struct {
|
|||
Config func() (config.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
Prompter iprompter
|
||||
BackOff backoff.BackOff
|
||||
|
||||
Name string
|
||||
Description string
|
||||
|
|
@ -387,17 +396,12 @@ func createFromScratch(opts *CreateOptions) error {
|
|||
|
||||
remoteURL := ghrepo.FormatRemoteURL(repo, protocol)
|
||||
|
||||
if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" {
|
||||
if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" && opts.Template == "" {
|
||||
// cloning empty repository or template
|
||||
checkoutBranch := ""
|
||||
if opts.Template != "" {
|
||||
// use the template's default branch
|
||||
checkoutBranch = templateRepoMainBranch
|
||||
}
|
||||
if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil {
|
||||
if err := localInit(opts.GitClient, remoteURL, repo.RepoName()); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil {
|
||||
} else if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -563,6 +567,33 @@ func createFromLocal(opts *CreateOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func cloneWithRetry(opts *CreateOptions, remoteURL, branch string) error {
|
||||
// Allow injecting alternative BackOff in tests.
|
||||
if opts.BackOff == nil {
|
||||
opts.BackOff = backoff.NewConstantBackOff(3 * time.Second)
|
||||
}
|
||||
|
||||
var args []string
|
||||
if branch != "" {
|
||||
args = append(args, "--branch", branch)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return backoff.Retry(func() error {
|
||||
stderr := &bytes.Buffer{}
|
||||
_, err := opts.GitClient.Clone(ctx, remoteURL, args, git.WithStderr(stderr))
|
||||
|
||||
var execError errWithExitCode
|
||||
if errors.As(err, &execError) && execError.ExitCode() == 128 {
|
||||
return err
|
||||
} else {
|
||||
_, _ = io.Copy(opts.IO.ErrOut, stderr)
|
||||
}
|
||||
|
||||
return backoff.Permanent(err)
|
||||
}, backoff.WithContext(backoff.WithMaxRetries(opts.BackOff, 3), ctx))
|
||||
}
|
||||
|
||||
func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error {
|
||||
cs := io.ColorScheme()
|
||||
remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL)
|
||||
|
|
@ -620,7 +651,7 @@ func isLocalRepo(gitClient *git.Client) (bool, error) {
|
|||
}
|
||||
|
||||
// clone the checkout branch to specified path
|
||||
func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error {
|
||||
func localInit(gitClient *git.Client, remoteURL, path string) error {
|
||||
ctx := context.Background()
|
||||
gitInit, err := gitClient.Command(ctx, "init", path)
|
||||
if err != nil {
|
||||
|
|
@ -644,17 +675,7 @@ func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) er
|
|||
return err
|
||||
}
|
||||
|
||||
if checkoutBranch == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
refspec := fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)
|
||||
err = gc.Fetch(ctx, "origin", refspec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gc.CheckoutBranch(ctx, checkoutBranch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
|
|
@ -119,6 +120,18 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
IncludeAllBranches: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template with .gitignore",
|
||||
cli: "template-repo --template mytemplate --gitignore ../.gitignore --public",
|
||||
wantsErr: true,
|
||||
errMsg: ".gitignore and license templates are not added when template is provided",
|
||||
},
|
||||
{
|
||||
name: "template with license",
|
||||
cli: "template-repo --template mytemplate --license ../.license --public",
|
||||
wantsErr: true,
|
||||
errMsg: ".gitignore and license templates are not added when template is provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -557,6 +570,98 @@ func Test_createRun(t *testing.T) {
|
|||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive clone from scratch",
|
||||
opts: &CreateOptions{
|
||||
Interactive: false,
|
||||
Name: "REPO",
|
||||
Visibility: "PRIVATE",
|
||||
Clone: true,
|
||||
},
|
||||
tty: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation RepositoryCreate\b`),
|
||||
httpmock.StringResponse(`
|
||||
{
|
||||
"data": {
|
||||
"createRepository": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"name": "REPO",
|
||||
"owner": {"login":"OWNER"},
|
||||
"url": "https://github.com/OWNER/REPO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
},
|
||||
execStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git init REPO`, 0, "")
|
||||
cs.Register(`git -C REPO remote add origin https://github.com/OWNER/REPO`, 0, "")
|
||||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive create from template with retry",
|
||||
opts: &CreateOptions{
|
||||
Interactive: false,
|
||||
Name: "REPO",
|
||||
Visibility: "PRIVATE",
|
||||
Clone: true,
|
||||
Template: "mytemplate",
|
||||
BackOff: &backoff.ZeroBackOff{},
|
||||
},
|
||||
tty: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
// Test resolving repo owner from repo name only.
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||
httpmock.GraphQLQuery(`{
|
||||
"data": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"defaultBranchRef": {
|
||||
"name": "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, func(s string, m map[string]interface{}) {
|
||||
assert.Equal(t, "OWNER", m["owner"])
|
||||
assert.Equal(t, "mytemplate", m["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"id":"OWNERID"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CloneTemplateRepository\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{
|
||||
"data": {
|
||||
"cloneTemplateRepository": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"name": "REPO",
|
||||
"owner": {"login":"OWNER"},
|
||||
"url": "https://github.com/OWNER/REPO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, func(m map[string]interface{}) {
|
||||
assert.Equal(t, "REPOID", m["repositoryId"])
|
||||
}))
|
||||
},
|
||||
execStubs: func(cs *run.CommandStubber) {
|
||||
// fatal: Remote branch main not found in upstream origin
|
||||
cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 128, "")
|
||||
cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "")
|
||||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
prompterMock := &prompter.PrompterMock{}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ func listRun(opts *ListOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.Owner != "" && listResult.Owner == "" && !listResult.FromSearch {
|
||||
return fmt.Errorf("the owner handle %q was not recognized as either a GitHub user or an organization", opts.Owner)
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err != nil {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,3 +452,40 @@ func TestRepoList_noVisibilityField(t *testing.T) {
|
|||
assert.Equal(t, "", stderr.String())
|
||||
assert.Equal(t, "", stdout.String())
|
||||
}
|
||||
|
||||
func TestRepoList_invalidOwner(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(false)
|
||||
ios.SetStdinTTY(false)
|
||||
ios.SetStderrTTY(false)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryList\b`),
|
||||
httpmock.StringResponse(`{ "data": { "repositoryOwner": null } }`),
|
||||
)
|
||||
|
||||
opts := ListOptions{
|
||||
Owner: "nonexist",
|
||||
IO: ios,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
Now: func() time.Time {
|
||||
t, _ := time.Parse(time.RFC822, "19 Feb 21 15:00 UTC")
|
||||
return t
|
||||
},
|
||||
Limit: 30,
|
||||
Detector: &fd.DisabledDetectorMock{},
|
||||
}
|
||||
|
||||
err := listRun(&opts)
|
||||
assert.EqualError(t, err, `the owner handle "nonexist" was not recognized as either a GitHub user or an organization`)
|
||||
assert.Equal(t, "", stderr.String())
|
||||
assert.Equal(t, "", stdout.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt
|
|||
}
|
||||
if currentBranch == branch {
|
||||
if isDirty, err := git.IsDirty(); err == nil && isDirty {
|
||||
return fmt.Errorf("can't sync because there are local changes; please stash them before trying again")
|
||||
return fmt.Errorf("refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ func Test_SyncRun(t *testing.T) {
|
|||
mgc.On("IsDirty").Return(true, nil).Once()
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "can't sync because there are local changes; please stash them before trying again",
|
||||
errMsg: "refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards",
|
||||
},
|
||||
{
|
||||
name: "sync local repo with parent - existing branch, non-current",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ With '--branch', view a specific branch of the repository.`,
|
|||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,8 +79,11 @@ var HelpTopics = map[string]map[string]string{
|
|||
checks for new releases once every 24 hours and displays an upgrade notice on standard
|
||||
error if a newer version was found.
|
||||
|
||||
GH_CONFIG_DIR: the directory where gh will store configuration files. Default:
|
||||
"$XDG_CONFIG_HOME/gh" or "$HOME/.config/gh".
|
||||
GH_CONFIG_DIR: the directory where gh will store configuration files. If not specified,
|
||||
the default value will be one of the following paths (in order of precedence):
|
||||
- "$XDG_CONFIG_HOME/gh" (if $XDG_CONFIG_HOME is set),
|
||||
- "$AppData/GitHub CLI" (on Windows if $AppData is set), or
|
||||
- "$HOME/.config/gh".
|
||||
|
||||
GH_PROMPT_DISABLED: set to any value to disable interactive prompting in the terminal.
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func runCancel(opts *CancelOptions) error {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
run, err = shared.GetRun(client, repo, runID)
|
||||
run, err = shared.GetRun(client, repo, runID, 0)
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type ListOptions struct {
|
|||
WorkflowSelector string
|
||||
Branch string
|
||||
Actor string
|
||||
Status string
|
||||
|
||||
now time.Time
|
||||
}
|
||||
|
|
@ -67,8 +68,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVarP(&opts.WorkflowSelector, "workflow", "w", "", "Filter runs by workflow")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Filter runs by branch")
|
||||
cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +91,7 @@ func listRun(opts *ListOptions) error {
|
|||
filters := &shared.FilterOptions{
|
||||
Branch: opts.Branch,
|
||||
Actor: opts.Actor,
|
||||
Status: opts.Status,
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ func TestNewCmdList(t *testing.T) {
|
|||
Actor: "bak1an",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cli: "--status completed",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Status: "completed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -115,6 +123,7 @@ func TestListRun(t *testing.T) {
|
|||
wantErr bool
|
||||
wantOut string
|
||||
wantErrOut string
|
||||
wantErrMsg string
|
||||
stubs func(*httpmock.Registry)
|
||||
isTTY bool
|
||||
}{
|
||||
|
|
@ -335,7 +344,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "workflow selector",
|
||||
|
|
@ -376,7 +386,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "actor filter applied",
|
||||
|
|
@ -392,7 +403,26 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "status filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Status: "queued",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"status": []string{"queued"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +446,7 @@ func TestListRun(t *testing.T) {
|
|||
err := listRun(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.wantErrMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func runRerun(opts *RerunOptions) error {
|
|||
}
|
||||
} else {
|
||||
opts.IO.StartProgressIndicator()
|
||||
run, err := shared.GetRun(client, repo, runID)
|
||||
run, err := shared.GetRun(client, repo, runID, 0)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,19 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
)
|
||||
|
||||
func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string) string {
|
||||
func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string, attempt uint64) string {
|
||||
title := fmt.Sprintf("%s %s%s",
|
||||
cs.Bold(run.HeadBranch), run.WorkflowName(), prNumber)
|
||||
symbol, symbolColor := Symbol(cs, run.Status, run.Conclusion)
|
||||
id := cs.Cyanf("%d", run.ID)
|
||||
|
||||
attemptLabel := ""
|
||||
if attempt > 0 {
|
||||
attemptLabel = fmt.Sprintf(" (Attempt #%d)", attempt)
|
||||
}
|
||||
|
||||
header := ""
|
||||
header += fmt.Sprintf("%s %s · %s\n", symbolColor(symbol), title, id)
|
||||
header += fmt.Sprintf("%s %s · %s%s\n", symbolColor(symbol), title, id, attemptLabel)
|
||||
header += fmt.Sprintf("Triggered via %s %s", run.Event, ago)
|
||||
|
||||
return header
|
||||
|
|
|
|||
|
|
@ -44,6 +44,23 @@ type Status string
|
|||
type Conclusion string
|
||||
type Level string
|
||||
|
||||
var AllStatuses = []string{
|
||||
"queued",
|
||||
"completed",
|
||||
"in_progress",
|
||||
"requested",
|
||||
"waiting",
|
||||
"action_required",
|
||||
"cancelled",
|
||||
"failure",
|
||||
"neutral",
|
||||
"skipped",
|
||||
"stale",
|
||||
"startup_failure",
|
||||
"success",
|
||||
"timed_out",
|
||||
}
|
||||
|
||||
var RunFields = []string{
|
||||
"name",
|
||||
"displayTitle",
|
||||
|
|
@ -284,6 +301,7 @@ type FilterOptions struct {
|
|||
WorkflowID int64
|
||||
// avoid loading workflow name separately and use the provided one
|
||||
WorkflowName string
|
||||
Status string
|
||||
}
|
||||
|
||||
// GetRunsWithFilter fetches 50 runs from the API and filters them in-memory
|
||||
|
|
@ -326,6 +344,9 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim
|
|||
if opts.Actor != "" {
|
||||
path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor))
|
||||
}
|
||||
if opts.Status != "" {
|
||||
path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status))
|
||||
}
|
||||
}
|
||||
|
||||
var result *RunsPayload
|
||||
|
|
@ -449,16 +470,27 @@ func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
|
|||
return fmt.Sprintf("%d", runs[selected].ID), nil
|
||||
}
|
||||
|
||||
func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
|
||||
func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) {
|
||||
var result Run
|
||||
|
||||
path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID)
|
||||
|
||||
if attempt > 0 {
|
||||
path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt)
|
||||
}
|
||||
|
||||
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if attempt > 0 {
|
||||
result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Set name to workflow name
|
||||
workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ type ViewOptions struct {
|
|||
Log bool
|
||||
LogFailed bool
|
||||
Web bool
|
||||
Attempt uint64
|
||||
|
||||
Prompt bool
|
||||
Exporter cmdutil.Exporter
|
||||
|
|
@ -101,6 +102,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
# View a specific run
|
||||
$ gh run view 12345
|
||||
|
||||
# View a specific run with specific attempt number
|
||||
$ gh run view 12345 --attempt 3
|
||||
|
||||
# View a specific job within a run
|
||||
$ gh run view --job 456789
|
||||
|
||||
|
|
@ -153,6 +157,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job")
|
||||
cmd.Flags().BoolVar(&opts.LogFailed, "log-failed", false, "View the log for any failed steps in a run or specific job")
|
||||
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open run in the browser")
|
||||
cmd.Flags().Uint64VarP(&opts.Attempt, "attempt", "a", 0, "The attempt number of the workflow run")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.SingleRunFields)
|
||||
|
||||
return cmd
|
||||
|
|
@ -172,6 +177,7 @@ func runView(opts *ViewOptions) error {
|
|||
|
||||
jobID := opts.JobID
|
||||
runID := opts.RunID
|
||||
attempt := opts.Attempt
|
||||
var selectedJob *shared.Job
|
||||
var run *shared.Run
|
||||
var jobs []shared.Job
|
||||
|
|
@ -206,7 +212,7 @@ func runView(opts *ViewOptions) error {
|
|||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
run, err = shared.GetRun(client, repo, runID)
|
||||
run, err = shared.GetRun(client, repo, runID, attempt)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
|
|
@ -271,7 +277,7 @@ func runView(opts *ViewOptions) error {
|
|||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run)
|
||||
runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run, attempt)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run log: %w", err)
|
||||
|
|
@ -318,7 +324,7 @@ func runView(opts *ViewOptions) error {
|
|||
out := opts.IO.Out
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber))
|
||||
fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, attempt))
|
||||
fmt.Fprintln(out)
|
||||
|
||||
if len(jobs) == 0 && run.Conclusion == shared.Failure || run.Conclusion == shared.StartupFailure {
|
||||
|
|
@ -425,7 +431,7 @@ func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) {
|
|||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run) (*zip.ReadCloser, error) {
|
||||
func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run, attempt uint64) (*zip.ReadCloser, error) {
|
||||
filename := fmt.Sprintf("run-log-%d-%d.zip", run.ID, run.StartedTime().Unix())
|
||||
filepath := filepath.Join(os.TempDir(), "gh-cli-cache", filename)
|
||||
if !cache.Exists(filepath) {
|
||||
|
|
@ -433,6 +439,11 @@ func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface
|
|||
logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs",
|
||||
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID)
|
||||
|
||||
if attempt > 0 {
|
||||
logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/attempts/%d/logs",
|
||||
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID, attempt)
|
||||
}
|
||||
|
||||
resp, err := getLog(httpClient, logURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@ func TestNewCmdView(t *testing.T) {
|
|||
JobID: "4567",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "run id with attempt",
|
||||
cli: "1234 --attempt 2",
|
||||
wants: ViewOptions{
|
||||
RunID: "1234",
|
||||
Attempt: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -154,6 +162,7 @@ func TestNewCmdView(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
|
||||
assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus)
|
||||
assert.Equal(t, tt.wants.Verbose, gotOpts.Verbose)
|
||||
assert.Equal(t, tt.wants.Attempt, gotOpts.Attempt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +222,50 @@ func TestViewRun(t *testing.T) {
|
|||
},
|
||||
wantOut: "\n✓ trunk CI #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n",
|
||||
},
|
||||
{
|
||||
name: "associate with PR with attempt",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
RunID: "3",
|
||||
Attempt: 3,
|
||||
Prompt: false,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"),
|
||||
httpmock.StringResponse(`{}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(`{"data": {
|
||||
"repository": {
|
||||
"pullRequests": {
|
||||
"nodes": [
|
||||
{"number": 2898,
|
||||
"headRepository": {
|
||||
"owner": {
|
||||
"login": "OWNER"
|
||||
},
|
||||
"name": "REPO"}}
|
||||
]}}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
wantOut: "\n✓ trunk CI #2898 · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n",
|
||||
},
|
||||
{
|
||||
name: "exit status, failed run",
|
||||
opts: &ViewOptions{
|
||||
|
|
@ -291,6 +344,52 @@ func TestViewRun(t *testing.T) {
|
|||
View this run on GitHub: https://github.com/runs/3
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "with artifacts and attempt",
|
||||
opts: &ViewOptions{
|
||||
RunID: "3",
|
||||
Attempt: 3,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"),
|
||||
httpmock.JSONResponse(map[string][]shared.Artifact{
|
||||
"artifacts": {
|
||||
shared.Artifact{Name: "artifact-1", Expired: false},
|
||||
shared.Artifact{Name: "artifact-2", Expired: true},
|
||||
shared.Artifact{Name: "artifact-3", Expired: false},
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
},
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
✓ trunk CI · 3 (Attempt #3)
|
||||
Triggered via push about 59 minutes ago
|
||||
|
||||
JOBS
|
||||
|
||||
|
||||
ARTIFACTS
|
||||
artifact-1
|
||||
artifact-2 (expired)
|
||||
artifact-3
|
||||
|
||||
For more information about a job, try: gh run view --job=<job-id>
|
||||
View this run on GitHub: https://github.com/runs/3/attempts/3
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "exit status, successful run",
|
||||
opts: &ViewOptions{
|
||||
|
|
@ -323,6 +422,39 @@ func TestViewRun(t *testing.T) {
|
|||
},
|
||||
wantOut: "\n✓ trunk CI · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n",
|
||||
},
|
||||
{
|
||||
name: "exit status, successful run, with attempt",
|
||||
opts: &ViewOptions{
|
||||
RunID: "3",
|
||||
Attempt: 3,
|
||||
ExitStatus: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"),
|
||||
httpmock.StringResponse(`{}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
},
|
||||
wantOut: "\n✓ trunk CI · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n",
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
tty: true,
|
||||
|
|
@ -455,6 +587,53 @@ func TestViewRun(t *testing.T) {
|
|||
},
|
||||
wantOut: coolJobRunLogOutput,
|
||||
},
|
||||
{
|
||||
name: "interactive with log and attempt",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
Attempt: 3,
|
||||
Log: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
shared.FailedJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"),
|
||||
httpmock.FileResponse("./fixtures/run_log.zip"))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
|
||||
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
|
||||
Workflows: []workflowShared.Workflow{
|
||||
shared.TestWorkflow,
|
||||
},
|
||||
}))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
|
||||
as.StubOne(2)
|
||||
//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
|
||||
as.StubOne(1)
|
||||
},
|
||||
wantOut: coolJobRunLogOutput,
|
||||
},
|
||||
{
|
||||
name: "noninteractive with log",
|
||||
opts: &ViewOptions{
|
||||
|
|
@ -477,6 +656,29 @@ func TestViewRun(t *testing.T) {
|
|||
},
|
||||
wantOut: coolJobRunLogOutput,
|
||||
},
|
||||
{
|
||||
name: "noninteractive with log and attempt",
|
||||
opts: &ViewOptions{
|
||||
JobID: "10",
|
||||
Attempt: 3,
|
||||
Log: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"),
|
||||
httpmock.FileResponse("./fixtures/run_log.zip"))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
},
|
||||
wantOut: coolJobRunLogOutput,
|
||||
},
|
||||
{
|
||||
name: "interactive with run log",
|
||||
tty: true,
|
||||
|
|
@ -597,6 +799,53 @@ func TestViewRun(t *testing.T) {
|
|||
},
|
||||
wantOut: quuxTheBarfLogOutput,
|
||||
},
|
||||
{
|
||||
name: "interactive with log-failed with attempt",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
Attempt: 3,
|
||||
LogFailed: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3"),
|
||||
httpmock.JSONResponse(shared.FailedRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/1234/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
shared.FailedJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3/logs"),
|
||||
httpmock.FileResponse("./fixtures/run_log.zip"))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
|
||||
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
|
||||
Workflows: []workflowShared.Workflow{
|
||||
shared.TestWorkflow,
|
||||
},
|
||||
}))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
|
||||
as.StubOne(4)
|
||||
//nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt
|
||||
as.StubOne(2)
|
||||
},
|
||||
wantOut: quuxTheBarfLogOutput,
|
||||
},
|
||||
{
|
||||
name: "noninteractive with log-failed",
|
||||
opts: &ViewOptions{
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ func watchRun(opts *WatchOptions) error {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
run, err = shared.GetRun(client, repo, runID)
|
||||
run, err = shared.GetRun(client, repo, runID, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
}
|
||||
|
|
@ -201,7 +201,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo
|
|||
|
||||
var err error
|
||||
|
||||
run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID))
|
||||
run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID), 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get run: %w", err)
|
||||
}
|
||||
|
|
@ -236,7 +236,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo
|
|||
return nil, fmt.Errorf("failed to get annotations: %w", annotationErr)
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber))
|
||||
fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, 0))
|
||||
fmt.Fprintln(out)
|
||||
|
||||
if len(jobs) == 0 {
|
||||
|
|
|
|||
|
|
@ -184,5 +184,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr
|
|||
cmd.Flags().StringVar(&opts.Query.Qualifiers.ReviewedBy, "reviewed-by", "", "Filter on `user` who reviewed")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Status, "checks", "", "", []string{"pending", "success", "failure"}, "Filter based on status of the checks")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,14 +79,18 @@ func runAdd(opts *AddOptions) error {
|
|||
|
||||
hostname, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
uploaded, err := SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
cs := opts.IO.ColorScheme()
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
if uploaded {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon())
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Public key already exists on your account\n", cs.SuccessIcon())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,33 +11,94 @@ import (
|
|||
)
|
||||
|
||||
func Test_runAdd(t *testing.T) {
|
||||
ios, stdin, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdinTTY(false)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
tests := []struct {
|
||||
name string
|
||||
stdin string
|
||||
opts AddOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid key format, not already in use",
|
||||
stdin: "ssh-ed25519 asdf",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse("[]"))
|
||||
reg.Register(
|
||||
httpmock.REST("POST", "user/keys"),
|
||||
httpmock.RESTPayload(200, ``, func(payload map[string]interface{}) {
|
||||
assert.Contains(t, payload, "key")
|
||||
assert.Empty(t, payload["title"])
|
||||
}))
|
||||
},
|
||||
wantStdout: "",
|
||||
wantStderr: "✓ Public key added to your account\n",
|
||||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "valid key format, already in use",
|
||||
stdin: "ssh-ed25519 asdf title",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(`[
|
||||
{
|
||||
"id": 1,
|
||||
"key": "ssh-ed25519 asdf",
|
||||
"title": "anything"
|
||||
}
|
||||
]`))
|
||||
},
|
||||
wantStdout: "",
|
||||
wantStderr: "✓ Public key already exists on your account\n",
|
||||
wantErrMsg: "",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "invalid key format",
|
||||
stdin: "ssh-ed25519",
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
wantErrMsg: "provided key is not in a valid format",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
}
|
||||
|
||||
stdin.WriteString("PUBKEY")
|
||||
for _, tt := range tests {
|
||||
ios, stdin, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdinTTY(false)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
tr := httpmock.Registry{}
|
||||
defer tr.Verify(t)
|
||||
stdin.WriteString(tt.stdin)
|
||||
|
||||
tr.Register(
|
||||
httpmock.REST("POST", "user/keys"),
|
||||
httpmock.StringResponse(`{}`))
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
err := runAdd(&AddOptions{
|
||||
IO: ios,
|
||||
Config: func() (config.Config, error) {
|
||||
tt.opts.IO = ios
|
||||
tt.opts.HTTPClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: &tr}, nil
|
||||
},
|
||||
KeyFile: "-",
|
||||
Title: "my sacred key",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
assert.Equal(t, "✓ Public key added to your account\n", stderr.String())
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer reg.Verify(t)
|
||||
err := runAdd(&tt.opts)
|
||||
if tt.wantErrMsg != "" {
|
||||
assert.Equal(t, tt.wantErrMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,50 +3,73 @@ package add
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/pkg/cmd/ssh-key/shared"
|
||||
)
|
||||
|
||||
func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error {
|
||||
// Uploads the provided SSH key. Returns true if the key was uploaded, false if it was not.
|
||||
func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) {
|
||||
url := ghinstance.RESTPrefix(hostname) + "user/keys"
|
||||
|
||||
keyBytes, err := io.ReadAll(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
fullUserKey := string(keyBytes)
|
||||
splitKey := strings.Fields(fullUserKey)
|
||||
if len(splitKey) < 2 {
|
||||
return false, errors.New("provided key is not in a valid format")
|
||||
}
|
||||
|
||||
keyToCompare := splitKey[0] + " " + splitKey[1]
|
||||
|
||||
keys, err := shared.UserKeys(httpClient, hostname, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if k.Key == keyToCompare {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"title": title,
|
||||
"key": string(keyBytes),
|
||||
"key": fullUserKey,
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return api.HandleHTTPError(resp)
|
||||
return false, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/ssh-key/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
|
|
@ -55,7 +56,7 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
sshKeys, err := userKeys(apiClient, host, "")
|
||||
sshKeys, err := shared.UserKeys(apiClient, host, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package list
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -18,7 +18,7 @@ type sshKey struct {
|
|||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func userKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
resource := "user/keys"
|
||||
if userHandle != "" {
|
||||
resource = fmt.Sprintf("users/%s/keys", userHandle)
|
||||
|
|
@ -128,6 +128,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command
|
|||
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Read workflow inputs as JSON via STDIN")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "ref")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
cmd.Flags().BoolVarP(&opts.YAML, "yaml", "y", false, "View the workflow yaml file")
|
||||
cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to view")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "ref")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -44,6 +45,26 @@ func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string
|
|||
return f
|
||||
}
|
||||
|
||||
type gitClient interface {
|
||||
TrackingBranchNames(context.Context, string) []string
|
||||
}
|
||||
|
||||
// RegisterBranchCompletionFlags suggests and autocompletes known remote git branches for flags passed
|
||||
func RegisterBranchCompletionFlags(gitc gitClient, cmd *cobra.Command, flags ...string) error {
|
||||
for _, flag := range flags {
|
||||
err := cmd.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if repoFlag := cmd.Flag("repo"); repoFlag != nil && repoFlag.Changed {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return gitc.TrackingBranchNames(context.TODO(), toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatValuesForUsageDocs(values []string) string {
|
||||
return fmt.Sprintf("{%s}", strings.Join(values, "|"))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue