Merge branch 'gh-org-command' of https://github.com/joshkraft/cli into gh-org-command

This commit is contained in:
Josh Kraft 2023-04-05 15:38:58 -06:00
commit 4d20db0ca0
49 changed files with 1142 additions and 138 deletions

View file

@ -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

View file

@ -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
```

View file

@ -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)
}

View file

@ -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,
})
}

View file

@ -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",
},
}

View file

@ -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(),

View file

@ -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()

View file

@ -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{}

View file

@ -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
}

View file

@ -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

View file

@ -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,
},
}

View file

@ -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 {

View file

@ -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)
})
}
}

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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)
})
}
}

View file

@ -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()

View file

@ -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
}

View file

@ -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

View file

@ -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())
}
})
}
}

View file

@ -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
}

View file

@ -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(&notesFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)")
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
return cmd
}

View file

@ -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) {

View file

@ -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{}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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",

View file

@ -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
}

View file

@ -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.

View file

@ -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) {

View file

@ -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()

View file

@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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{

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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())
})
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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, "|"))
}