Merge branch 'trunk' into gh-search-code
This commit is contained in:
commit
b8f86f54ad
133 changed files with 3967 additions and 515 deletions
|
|
@ -23,8 +23,8 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/jsoncolor"
|
||||
"github.com/cli/go-gh/pkg/jq"
|
||||
"github.com/cli/go-gh/pkg/template"
|
||||
"github.com/cli/go-gh/v2/pkg/jq"
|
||||
"github.com/cli/go-gh/v2/pkg/template"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ func apiRun(opts *ApiOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl)
|
||||
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -387,7 +387,11 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
|
||||
if opts.FilterOutput != "" && serverError == "" {
|
||||
// TODO: reuse parsed query across pagination invocations
|
||||
err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput)
|
||||
indent := ""
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
indent = " "
|
||||
}
|
||||
err = jq.EvaluateFormatted(responseBody, bodyWriter, opts.FilterOutput, indent, opts.IO.ColorEnabled())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/go-gh/pkg/template"
|
||||
"github.com/cli/go-gh/v2/pkg/template"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -378,6 +378,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err error
|
||||
stdout string
|
||||
stderr string
|
||||
isatty bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
|
|
@ -388,6 +389,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: `bam!`,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "show response headers",
|
||||
|
|
@ -404,6 +406,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "success 204",
|
||||
|
|
@ -414,6 +417,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: ``,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "REST error",
|
||||
|
|
@ -425,6 +429,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "REST string errors",
|
||||
|
|
@ -436,6 +441,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"errors": ["ALSO", "FINE"]}`,
|
||||
stderr: "gh: ALSO\nFINE\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "GraphQL error",
|
||||
|
|
@ -450,6 +456,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`,
|
||||
stderr: "gh: AGAIN\nFINE\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "failure",
|
||||
|
|
@ -460,6 +467,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `gateway timeout`,
|
||||
stderr: "gh: HTTP 502\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "silent",
|
||||
|
|
@ -473,6 +481,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: ``,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "show response headers even when silent",
|
||||
|
|
@ -490,6 +499,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "output template",
|
||||
|
|
@ -504,6 +514,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "not a cat",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "output template when REST error",
|
||||
|
|
@ -518,6 +529,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter",
|
||||
|
|
@ -532,6 +544,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "Mona\nHubot\n",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter when REST error",
|
||||
|
|
@ -546,12 +559,29 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter outputting JSON to a TTY",
|
||||
options: ApiOptions{
|
||||
FilterOutput: `.`,
|
||||
},
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"name":"Mona"},{"name":"Hubot"}]`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
},
|
||||
err: nil,
|
||||
stdout: "[\n {\n \"name\": \"Mona\"\n },\n {\n \"name\": \"Hubot\"\n }\n]\n",
|
||||
stderr: ``,
|
||||
isatty: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(tt.isatty)
|
||||
|
||||
tt.options.IO = ios
|
||||
tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil }
|
||||
|
|
@ -1206,7 +1236,7 @@ func Test_processResponse_template(t *testing.T) {
|
|||
tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled())
|
||||
err := tmpl.Parse(opts.Template)
|
||||
require.NoError(t, err)
|
||||
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, &tmpl)
|
||||
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, tmpl)
|
||||
require.NoError(t, err)
|
||||
err = tmpl.Flush()
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -150,6 +151,11 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
// The go-gh Config object currently does not support case-insensitive lookups for host names,
|
||||
// so normalize the host name case here before performing any lookups with it or persisting it.
|
||||
// https://github.com/cli/go-gh/pull/105
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src)
|
||||
fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n")
|
||||
|
|
@ -166,7 +172,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 +201,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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ type RefreshOptions struct {
|
|||
Scopes []string
|
||||
AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error
|
||||
|
||||
Interactive bool
|
||||
SecureStorage bool
|
||||
Interactive bool
|
||||
InsecureStorage bool
|
||||
}
|
||||
|
||||
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
|
||||
|
|
@ -90,7 +90,12 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
|
|||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication")
|
||||
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
|
||||
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().BoolVarP(&opts.InsecureStorage, "insecure-storage", "", false, "Save authentication credentials in plain text instead of credential store")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -139,7 +144,7 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
}
|
||||
|
||||
var additionalScopes []string
|
||||
if oldToken, source := authCfg.Token(hostname); oldToken != "" {
|
||||
if oldToken, _ := authCfg.Token(hostname); oldToken != "" {
|
||||
if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil {
|
||||
for _, s := range strings.Split(oldScopes, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
|
|
@ -148,13 +153,6 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If previous token was stored in secure storage assume
|
||||
// user wants to continue storing it there even if not
|
||||
// explicitly stated with the secure storage flag.
|
||||
if source == "keyring" {
|
||||
opts.SecureStorage = true
|
||||
}
|
||||
}
|
||||
|
||||
credentialFlow := &shared.GitCredentialFlow{
|
||||
|
|
@ -170,7 +168,7 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
|
||||
}
|
||||
|
||||
if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, opts.SecureStorage); err != nil {
|
||||
if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, !opts.InsecureStorage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,11 +86,17 @@ func Test_NewCmdRefresh(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "secure storage",
|
||||
name: "secure storage",
|
||||
tty: true,
|
||||
cli: "--secure-storage",
|
||||
wants: RefreshOptions{},
|
||||
},
|
||||
{
|
||||
name: "insecure storage",
|
||||
tty: true,
|
||||
cli: "--secure-storage",
|
||||
cli: "--insecure-storage",
|
||||
wants: RefreshOptions{
|
||||
SecureStorage: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -178,8 +184,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
Hostname: "obed.morton",
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -191,8 +198,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
Hostname: "",
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -210,8 +218,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
}
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -223,12 +232,13 @@ func Test_refreshRun(t *testing.T) {
|
|||
Scopes: []string{"repo:invite", "public_key:read"},
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read"},
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read"},
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scopes provided",
|
||||
name: "more scopes provided",
|
||||
cfgHosts: []string{
|
||||
"github.com",
|
||||
},
|
||||
|
|
@ -237,37 +247,16 @@ func Test_refreshRun(t *testing.T) {
|
|||
Scopes: []string{"repo:invite", "public_key:read"},
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit secure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
SecureStorage: true,
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit secure storage",
|
||||
config: func() config.Config {
|
||||
cfg := config.NewFromString("")
|
||||
authCfg := cfg.Authentication()
|
||||
authCfg.SetHosts([]string{"obed.morton"})
|
||||
authCfg.SetToken("abc123", "keyring")
|
||||
cfg.AuthenticationFunc = func() *config.AuthConfig {
|
||||
return authCfg
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
name: "secure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
},
|
||||
|
|
@ -277,6 +266,20 @@ func Test_refreshRun(t *testing.T) {
|
|||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insecure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -349,7 +369,7 @@ func Test_runBrowse(t *testing.T) {
|
|||
opts: BrowseOptions{
|
||||
SelectorArg: "chocolate-pecan-pie.txt",
|
||||
},
|
||||
baseRepo: ghrepo.New("andrewhsu", "recipies"),
|
||||
baseRepo: ghrepo.New("andrewhsu", "recipes"),
|
||||
defaultBranch: "",
|
||||
wantsErr: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ type createOptions struct {
|
|||
idleTimeout time.Duration
|
||||
retentionPeriod NullableDuration
|
||||
displayName string
|
||||
useWeb bool
|
||||
}
|
||||
|
||||
func newCreateCmd(app *App) *cobra.Command {
|
||||
|
|
@ -78,11 +79,20 @@ func newCreateCmd(app *App) *cobra.Command {
|
|||
Use: "create",
|
||||
Short: "Create a codespace",
|
||||
Args: noArgsConstraint,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmdutil.MutuallyExclusive(
|
||||
"using --web with --display-name, --idle-timeout, or --retention-period is not supported",
|
||||
opts.useWeb,
|
||||
opts.displayName != "" || opts.idleTimeout != 0 || opts.retentionPeriod.Duration != nil,
|
||||
)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return app.Create(cmd.Context(), opts)
|
||||
},
|
||||
}
|
||||
|
||||
createCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "create codespace from browser, cannot be used with --display-name, --idle-timeout, or --retention-period")
|
||||
|
||||
createCmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "repository name with owner: user/repo")
|
||||
if err := addDeprecatedRepoShorthand(createCmd, &opts.repo); err != nil {
|
||||
fmt.Fprintf(app.io.ErrOut, "%v\n", err)
|
||||
|
|
@ -118,7 +128,11 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
Location: opts.location,
|
||||
}
|
||||
|
||||
promptForRepoAndBranch := userInputs.Repository == ""
|
||||
if opts.useWeb && userInputs.Repository == "" {
|
||||
return a.browser.Browse("https://github.com/codespaces/new")
|
||||
}
|
||||
|
||||
promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb
|
||||
if promptForRepoAndBranch {
|
||||
var defaultRepo string
|
||||
if remotes, _ := a.remotes(); remotes != nil {
|
||||
|
|
@ -249,12 +263,19 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting machine type: %w", err)
|
||||
}
|
||||
if machine == "" {
|
||||
return errors.New("there are no available machine types for this repository")
|
||||
machine := opts.machine
|
||||
// skip this if we have useWeb and no machine name provided,
|
||||
// because web UI will select default machine type if none is provided
|
||||
// web UI also provide a way to select machine type
|
||||
// therefore we let the user choose from the web UI instead of prompting from CLI
|
||||
if !(opts.useWeb && opts.machine == "") {
|
||||
machine, err = getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting machine type: %w", err)
|
||||
}
|
||||
if machine == "" {
|
||||
return errors.New("there are no available machine types for this repository")
|
||||
}
|
||||
}
|
||||
|
||||
createParams := &api.CreateCodespaceParams{
|
||||
|
|
@ -271,6 +292,10 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
DisplayName: opts.displayName,
|
||||
}
|
||||
|
||||
if opts.useWeb {
|
||||
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location))
|
||||
}
|
||||
|
||||
var codespace *api.Codespace
|
||||
err = a.RunWithProgress("Creating codespace", func() (err error) {
|
||||
codespace, err = a.apiClient.CreateCodespace(ctx, createParams)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,60 @@
|
|||
package codespace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateCmdFlagError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantsErr error
|
||||
}{
|
||||
{
|
||||
name: "return error when using web flag with display-name, idle-timeout, or retention-period flags",
|
||||
args: "--web --display-name foo --idle-timeout 30m",
|
||||
wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"),
|
||||
},
|
||||
{
|
||||
name: "return error when using web flag with one of display-name, idle-timeout or retention-period flags",
|
||||
args: "--web --idle-timeout 30m",
|
||||
wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
}
|
||||
cmd := newCreateCmd(a)
|
||||
|
||||
args, _ := shlex.Split(tt.args)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, tt.wantsErr.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_Create(t *testing.T) {
|
||||
type fields struct {
|
||||
apiClient apiClient
|
||||
|
|
@ -23,6 +66,7 @@ func TestApp_Create(t *testing.T) {
|
|||
wantErr error
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantURL string
|
||||
isTTY bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -115,6 +159,31 @@ func TestApp_Create(t *testing.T) {
|
|||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
},
|
||||
{
|
||||
name: "create codespace with nonexistent machine results in error",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
|
||||
return []*api.Machine{
|
||||
{
|
||||
Name: "GIGA",
|
||||
DisplayName: "Gigabits of a machine",
|
||||
},
|
||||
{
|
||||
Name: "TERA",
|
||||
DisplayName: "Terabits of a machine",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
machine: "MEGA",
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantErr: fmt.Errorf("error getting machine type: there is no such machine for the repository: %s\nAvailable machines: %v", "MEGA", []string{"GIGA", "TERA"}),
|
||||
},
|
||||
{
|
||||
name: "create codespace with devcontainer path results in selecting the correct machine type",
|
||||
fields: fields{
|
||||
|
|
@ -382,7 +451,95 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
},
|
||||
wantStdout: "megacorp-private-abcd1234\n",
|
||||
},
|
||||
{
|
||||
name: "return default url when using web flag without other flags",
|
||||
opts: createOptions{
|
||||
useWeb: true,
|
||||
},
|
||||
wantURL: "https://github.com/codespaces/new",
|
||||
},
|
||||
{
|
||||
name: "skip machine check when using web flag and no machine provided",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
useWeb: true,
|
||||
branch: "custom",
|
||||
location: "EastUS",
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "", "EastUS"),
|
||||
},
|
||||
{
|
||||
name: "return correct url with correct params when using web flag and repo flag",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
useWeb: true,
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "main", "", ""),
|
||||
},
|
||||
{
|
||||
name: "return correct url with correct params when using web flag, repo, branch, location, machine flag",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
Machine: api.CodespaceMachine{Name: "GIGA"},
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
machine: "GIGA",
|
||||
branch: "custom",
|
||||
location: "EastUS",
|
||||
useWeb: true,
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "GIGA", "EastUS"),
|
||||
},
|
||||
}
|
||||
var a *App
|
||||
var b *browser.Stub
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
|
@ -390,9 +547,18 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
ios.SetStdinTTY(tt.isTTY)
|
||||
ios.SetStderrTTY(tt.isTTY)
|
||||
|
||||
a := &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
if tt.opts.useWeb {
|
||||
b = &browser.Stub{}
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
browser: b,
|
||||
}
|
||||
} else {
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
}
|
||||
|
||||
err := a.Create(context.Background(), tt.opts)
|
||||
|
|
@ -410,6 +576,10 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
t.Logf(t.Name())
|
||||
t.Errorf(" stderr = %v, want %v", got, tt.wantStderr)
|
||||
}
|
||||
|
||||
if tt.opts.useWeb {
|
||||
b.Verify(t, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,23 +45,29 @@ func (a *App) Jupyter(ctx context.Context, selector *CodespaceSelector) (err err
|
|||
}
|
||||
defer safeClose(session, &err)
|
||||
|
||||
serverPort, serverUrl := 0, ""
|
||||
var (
|
||||
invoker rpc.Invoker
|
||||
serverPort int
|
||||
serverUrl string
|
||||
)
|
||||
err = a.RunWithProgress("Starting JupyterLab on codespace", func() (err error) {
|
||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||
invoker, err = rpc.CreateInvoker(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer safeClose(invoker, &err)
|
||||
|
||||
serverPort, serverUrl, err = invoker.StartJupyterServer(ctx)
|
||||
return
|
||||
})
|
||||
if invoker != nil {
|
||||
defer safeClose(invoker, &err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass 0 to pick a random port
|
||||
listen, _, err := codespaces.ListenTCP(0)
|
||||
listen, _, err := codespaces.ListenTCP(0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type listOptions struct {
|
|||
repo string
|
||||
orgName string
|
||||
userName string
|
||||
useWeb bool
|
||||
}
|
||||
|
||||
func newListCmd(app *App) *cobra.Command {
|
||||
|
|
@ -34,11 +35,29 @@ func newListCmd(app *App) *cobra.Command {
|
|||
`),
|
||||
Aliases: []string{"ls"},
|
||||
Args: noArgsConstraint,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"using `--org` or `--user` with `--repo` is not allowed",
|
||||
opts.repo != "",
|
||||
opts.orgName != "" || opts.userName != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead",
|
||||
opts.useWeb,
|
||||
opts.orgName != "" || opts.userName != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.limit < 1 {
|
||||
return cmdutil.FlagErrorf("invalid limit: %v", opts.limit)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return app.List(cmd.Context(), opts, exporter)
|
||||
},
|
||||
}
|
||||
|
|
@ -53,13 +72,14 @@ func newListCmd(app *App) *cobra.Command {
|
|||
listCmd.Flags().StringVarP(&opts.userName, "user", "u", "", "The `username` to list codespaces for (used with --org)")
|
||||
cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields)
|
||||
|
||||
listCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "List codespaces in the web browser, cannot be used with --user or --org")
|
||||
|
||||
return listCmd
|
||||
}
|
||||
|
||||
func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Exporter) error {
|
||||
// if repo is provided, we don't accept orgName or userName
|
||||
if opts.repo != "" && (opts.orgName != "" || opts.userName != "") {
|
||||
return cmdutil.FlagErrorf("using `--org` or `--user` with `--repo` is not allowed")
|
||||
if opts.useWeb && opts.repo == "" {
|
||||
return a.browser.Browse("https://github.com/codespaces")
|
||||
}
|
||||
|
||||
var codespaces []*api.Codespace
|
||||
|
|
@ -92,6 +112,10 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
|
|||
return cmdutil.NewNoResultsError("no codespaces found")
|
||||
}
|
||||
|
||||
if opts.useWeb && codespaces[0].Repository.ID > 0 {
|
||||
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces?repository_id=%d", codespaces[0].Repository.ID))
|
||||
}
|
||||
|
||||
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
|
||||
tp := utils.NewTablePrinter(a.io)
|
||||
if tp.IsTTY() {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,68 @@
|
|||
package codespace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListCmdFlagError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantsErr error
|
||||
}{
|
||||
{
|
||||
name: "list codespaces,--repo, --org and --user flag",
|
||||
args: "--repo foo/bar --org github --user github",
|
||||
wantsErr: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"),
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--web, --org and --user flag",
|
||||
args: "--web --org github --user github",
|
||||
wantsErr: fmt.Errorf("using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead"),
|
||||
},
|
||||
{
|
||||
name: "list codespaces, negative --limit flag",
|
||||
args: "--limit -1",
|
||||
wantsErr: fmt.Errorf("invalid limit: -1"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
}
|
||||
|
||||
cmd := newListCmd(a)
|
||||
|
||||
args, _ := shlex.Split(tt.args)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
|
||||
if tt.wantsErr != nil {
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, tt.wantsErr.Error())
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_List(t *testing.T) {
|
||||
type fields struct {
|
||||
apiClient apiClient
|
||||
|
|
@ -19,6 +72,7 @@ func TestApp_List(t *testing.T) {
|
|||
fields fields
|
||||
opts *listOptions
|
||||
wantError error
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "list codespaces, no flags",
|
||||
|
|
@ -115,22 +169,66 @@ func TestApp_List(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--repo, --org and --user flag",
|
||||
name: "list codespaces,--web",
|
||||
opts: &listOptions{
|
||||
repo: "cli/cli",
|
||||
orgName: "TestOrg",
|
||||
userName: "jimmy",
|
||||
useWeb: true,
|
||||
},
|
||||
wantError: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"),
|
||||
wantURL: "https://github.com/codespaces",
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--web, --repo flag",
|
||||
fields: fields{
|
||||
apiClient: &apiClientMock{
|
||||
ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
||||
if opts.RepoName == "" {
|
||||
return nil, fmt.Errorf("Expected repository to not be nil")
|
||||
}
|
||||
if opts.RepoName != "cli/cli" {
|
||||
return nil, fmt.Errorf("Expected repository name to be cli/cli. Got %s", opts.RepoName)
|
||||
}
|
||||
if opts.OrgName != "" {
|
||||
return nil, fmt.Errorf("Expected orgName to be blank. Got %s", opts.OrgName)
|
||||
}
|
||||
if opts.UserName != "" {
|
||||
return nil, fmt.Errorf("Expected userName to be blank. Got %s", opts.UserName)
|
||||
}
|
||||
return []*api.Codespace{
|
||||
{
|
||||
DisplayName: "CS1",
|
||||
Repository: api.Repository{ID: 123},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
opts: &listOptions{
|
||||
useWeb: true,
|
||||
repo: "cli/cli",
|
||||
},
|
||||
wantURL: "https://github.com/codespaces?repository_id=123",
|
||||
},
|
||||
}
|
||||
|
||||
var b *browser.Stub
|
||||
var a *App
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
if tt.opts.useWeb {
|
||||
b = &browser.Stub{}
|
||||
a = &App{
|
||||
browser: b,
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
} else {
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
}
|
||||
|
||||
var exporter cmdutil.Exporter
|
||||
|
||||
err := a.List(context.Background(), tt.opts, exporter)
|
||||
|
|
@ -142,6 +240,10 @@ func TestApp_List(t *testing.T) {
|
|||
if err != nil && err.Error() != tt.wantError.Error() {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantError)
|
||||
}
|
||||
|
||||
if tt.opts.useWeb {
|
||||
b.Verify(t, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func (a *App) Logs(ctx context.Context, selector *CodespaceSelector, follow bool
|
|||
defer safeClose(session, &err)
|
||||
|
||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||
listen, localPort, err := codespaces.ListenTCP(0)
|
||||
listen, localPort, err := codespaces.ListenTCP(0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Co
|
|||
|
||||
var container devContainer
|
||||
if err := json.Unmarshal(convertedJSON, &container); err != nil {
|
||||
ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)}
|
||||
ch <- devContainerResult{nil, fmt.Errorf("error unmarshalling: %w", err)}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -379,7 +379,7 @@ func (a *App) ForwardPorts(ctx context.Context, selector *CodespaceSelector, por
|
|||
for _, pair := range portPairs {
|
||||
pair := pair
|
||||
group.Go(func() error {
|
||||
listen, _, err := codespaces.ListenTCP(pair.local)
|
||||
listen, _, err := codespaces.ListenTCP(pair.local, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ func TestPendingOperationDisallowsListPorts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPendingOperationDisallowsUpdatePortVisability(t *testing.T) {
|
||||
func TestPendingOperationDisallowsUpdatePortVisibility(t *testing.T) {
|
||||
app := testingPortsApp()
|
||||
selector := &CodespaceSelector{api: app.apiClient, codespaceName: "disabledCodespace"}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,14 @@ func newSSHCmd(app *App) *cobra.Command {
|
|||
The 'ssh' command is used to SSH into a codespace. In its simplest form, you can
|
||||
run 'gh cs ssh', select a codespace interactively, and connect.
|
||||
|
||||
By default, the 'ssh' command will create a public/private ssh key pair to
|
||||
authenticate with the codespace inside the ~/.ssh directory.
|
||||
The 'ssh' command will automatically create a public/private ssh key pair in the
|
||||
~/.ssh directory if you do not have an existing valid key pair. When selecting the
|
||||
key pair to use, the preferred order is:
|
||||
|
||||
1. Key specified by -i in <ssh-flags>
|
||||
2. Automatic key, if it already exists
|
||||
3. First valid key pair in ssh config (according to ssh -G)
|
||||
4. Automatic key, newly created
|
||||
|
||||
The 'ssh' command also supports deeper integration with OpenSSH using a '--config'
|
||||
option that generates per-codespace ssh configuration in OpenSSH format.
|
||||
|
|
@ -171,17 +177,23 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
|||
}
|
||||
defer safeClose(session, &err)
|
||||
|
||||
remoteSSHServerPort, sshUser := 0, ""
|
||||
var (
|
||||
invoker rpc.Invoker
|
||||
remoteSSHServerPort int
|
||||
sshUser string
|
||||
)
|
||||
err = a.RunWithProgress("Fetching SSH Details", func() (err error) {
|
||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||
invoker, err = rpc.CreateInvoker(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer safeClose(invoker, &err)
|
||||
|
||||
remoteSSHServerPort, sshUser, err = invoker.StartSSHServerWithOptions(ctx, startSSHOptions)
|
||||
return
|
||||
})
|
||||
if invoker != nil {
|
||||
defer safeClose(invoker, &err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting ssh server details: %w", err)
|
||||
}
|
||||
|
|
@ -199,7 +211,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
|||
// Ensure local port is listening before client (Shell) connects.
|
||||
// Unless the user specifies a server port, localSSHServerPort is 0
|
||||
// and thus the client will pick a random port.
|
||||
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort)
|
||||
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ func (a *App) StopCodespace(ctx context.Context, opts *stopOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
err := a.RunWithProgress("Stoppping codespace", func() (err error) {
|
||||
err := a.RunWithProgress("Stopping codespace", func() (err error) {
|
||||
err = a.apiClient.StopCodespace(ctx, codespaceName, opts.orgName, ownerName)
|
||||
return
|
||||
})
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
|
||||
// Qualifier flags
|
||||
cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type")
|
||||
cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner")
|
||||
cmd.Flags().StringSliceVar(&qualifiers.User, "owner", nil, "Filter on owner")
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cli/go-gh"
|
||||
"github.com/cli/go-gh/v2/pkg/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("hi world, this is the %s extension!")
|
||||
client, err := gh.RESTClient(nil)
|
||||
client, err := api.DefaultRESTClient()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -713,7 +713,7 @@ func TestManager_UpgradeExtension_BinaryExtension_Pinned(t *testing.T) {
|
|||
assert.Equal(t, err, pinnedExtensionUpgradeError)
|
||||
}
|
||||
|
||||
func TestManager_UpgradeExtenion_GitExtension_Pinned(t *testing.T) {
|
||||
func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
extDir := filepath.Join(tempDir, "extensions", "gh-remote")
|
||||
assert.NoError(t, stubPinnedExtension(filepath.Join(extDir, "gh-remote"), "abcd1234"))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/go-gh/pkg/ssh"
|
||||
"github.com/cli/go-gh/v2/pkg/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package edit
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
shared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -24,8 +27,8 @@ type EditOptions struct {
|
|||
EditFieldsSurvey func(*prShared.Editable, string) error
|
||||
FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error
|
||||
|
||||
SelectorArg string
|
||||
Interactive bool
|
||||
SelectorArgs []string
|
||||
Interactive bool
|
||||
|
||||
prShared.Editable
|
||||
}
|
||||
|
|
@ -43,12 +46,12 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
var bodyFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit {<number> | <url>}",
|
||||
Short: "Edit an issue",
|
||||
Use: "edit {<numbers> | <urls>}",
|
||||
Short: "Edit issues",
|
||||
Long: heredoc.Doc(`
|
||||
Edit an issue.
|
||||
Edit one or more issues within the same repository.
|
||||
|
||||
Editing an issue's projects requires authorization with the "project" scope.
|
||||
Editing issues' projects requires authorization with the "project" scope.
|
||||
To authorize, run "gh auth refresh -s project".
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
|
|
@ -58,13 +61,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
$ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2
|
||||
$ gh issue edit 23 --milestone "Version 1"
|
||||
$ gh issue edit 23 --body-file body.txt
|
||||
$ gh issue edit 23 34 --add-label "help wanted"
|
||||
`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
opts.SelectorArg = args[0]
|
||||
opts.SelectorArgs = args
|
||||
|
||||
flags := cmd.Flags()
|
||||
|
||||
|
|
@ -113,6 +117,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
return cmdutil.FlagErrorf("field to edit flag required when not running interactively")
|
||||
}
|
||||
|
||||
if opts.Interactive && len(opts.SelectorArgs) > 1 {
|
||||
return cmdutil.FlagErrorf("multiple issues cannot be edited interactively")
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
|
@ -141,41 +149,8 @@ func editRun(opts *EditOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Prompt the user which fields they'd like to edit.
|
||||
editable := opts.Editable
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if opts.Interactive || editable.Assignees.Edited {
|
||||
lookupFields = append(lookupFields, "assignees")
|
||||
}
|
||||
if opts.Interactive || editable.Labels.Edited {
|
||||
lookupFields = append(lookupFields, "labels")
|
||||
}
|
||||
if opts.Interactive || editable.Projects.Edited {
|
||||
lookupFields = append(lookupFields, "projectCards")
|
||||
lookupFields = append(lookupFields, "projectItems")
|
||||
}
|
||||
if opts.Interactive || editable.Milestone.Edited {
|
||||
lookupFields = append(lookupFields, "milestone")
|
||||
}
|
||||
|
||||
issue, repo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
editable.Title.Default = issue.Title
|
||||
editable.Body.Default = issue.Body
|
||||
editable.Assignees.Default = issue.Assignees.Logins()
|
||||
editable.Labels.Default = issue.Labels.Names()
|
||||
editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if issue.Milestone != nil {
|
||||
editable.Milestone.Default = issue.Milestone.Title
|
||||
}
|
||||
|
||||
if opts.Interactive {
|
||||
err = opts.FieldsToEditSurvey(&editable)
|
||||
if err != nil {
|
||||
|
|
@ -183,33 +158,122 @@ func editRun(opts *EditOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if editable.Assignees.Edited {
|
||||
lookupFields = append(lookupFields, "assignees")
|
||||
}
|
||||
if editable.Labels.Edited {
|
||||
lookupFields = append(lookupFields, "labels")
|
||||
}
|
||||
if editable.Projects.Edited {
|
||||
lookupFields = append(lookupFields, "projectCards")
|
||||
lookupFields = append(lookupFields, "projectItems")
|
||||
}
|
||||
if editable.Milestone.Edited {
|
||||
lookupFields = append(lookupFields, "milestone")
|
||||
}
|
||||
|
||||
// Get all specified issues and make sure they are within the same repo.
|
||||
issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch editable shared fields once for all issues.
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
|
||||
err = opts.FetchOptions(apiClient, repo, &editable)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Interactive {
|
||||
editorCommand, err := opts.DetermineEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = opts.EditFieldsSurvey(&editable, editorCommand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Update all issues in parallel.
|
||||
editedIssueChan := make(chan string, len(issues))
|
||||
failedIssueChan := make(chan string, len(issues))
|
||||
g := sync.WaitGroup{}
|
||||
|
||||
// Only show progress if we will not prompt below or the survey will break up the progress indicator.
|
||||
if !opts.Interactive {
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues)))
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
err = prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable)
|
||||
for _, issue := range issues {
|
||||
// Copy variables to capture in the go routine below.
|
||||
editable := editable.Clone()
|
||||
|
||||
editable.Title.Default = issue.Title
|
||||
editable.Body.Default = issue.Body
|
||||
editable.Assignees.Default = issue.Assignees.Logins()
|
||||
editable.Labels.Default = issue.Labels.Names()
|
||||
editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if issue.Milestone != nil {
|
||||
editable.Milestone.Default = issue.Milestone.Title
|
||||
}
|
||||
|
||||
// Allow interactive prompts for one issue; failed earlier if multiple issues specified.
|
||||
if opts.Interactive {
|
||||
editorCommand, err := opts.DetermineEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = opts.EditFieldsSurvey(&editable, editorCommand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
g.Add(1)
|
||||
go func(issue *api.Issue) {
|
||||
defer g.Done()
|
||||
|
||||
err := prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable)
|
||||
if err != nil {
|
||||
failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err)
|
||||
return
|
||||
}
|
||||
|
||||
editedIssueChan <- issue.URL
|
||||
}(issue)
|
||||
}
|
||||
|
||||
g.Wait()
|
||||
close(editedIssueChan)
|
||||
close(failedIssueChan)
|
||||
|
||||
// Does nothing if progress was not started above.
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
// Print a sorted list of successfully edited issue URLs to stdout.
|
||||
editedIssueURLs := make([]string, 0, len(issues))
|
||||
for editedIssueURL := range editedIssueChan {
|
||||
editedIssueURLs = append(editedIssueURLs, editedIssueURL)
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.IO.Out, issue.URL)
|
||||
sort.Strings(editedIssueURLs)
|
||||
for _, editedIssueURL := range editedIssueURLs {
|
||||
fmt.Fprintln(opts.IO.Out, editedIssueURL)
|
||||
}
|
||||
|
||||
// Print a sorted list of failures to stderr.
|
||||
failedIssueErrors := make([]string, 0, len(issues))
|
||||
for failedIssueError := range failedIssueChan {
|
||||
failedIssueErrors = append(failedIssueErrors, failedIssueError)
|
||||
}
|
||||
|
||||
sort.Strings(failedIssueErrors)
|
||||
for _, failedIssueError := range failedIssueErrors {
|
||||
fmt.Fprintln(opts.IO.ErrOut, failedIssueError)
|
||||
}
|
||||
|
||||
if len(failedIssueErrors) > 0 {
|
||||
return fmt.Errorf("failed to update %s", text.Pluralize(len(failedIssueErrors), "issue"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
|
|
@ -41,8 +42,8 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "issue number argument",
|
||||
input: "23",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Interactive: true,
|
||||
SelectorArgs: []string{"23"},
|
||||
Interactive: true,
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -50,7 +51,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "title flag",
|
||||
input: "23 --title test",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Title: prShared.EditableString{
|
||||
Value: "test",
|
||||
|
|
@ -64,7 +65,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "body flag",
|
||||
input: "23 --body test",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "test",
|
||||
|
|
@ -79,7 +80,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
input: "23 --body-file -",
|
||||
stdin: "this is on standard input",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "this is on standard input",
|
||||
|
|
@ -93,7 +94,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "body from file",
|
||||
input: fmt.Sprintf("23 --body-file '%s'", tmpFile),
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "a body from file",
|
||||
|
|
@ -107,7 +108,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-assignee flag",
|
||||
input: "23 --add-assignee monalisa,hubot",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
|
|
@ -121,7 +122,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-assignee flag",
|
||||
input: "23 --remove-assignee monalisa,hubot",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Remove: []string{"monalisa", "hubot"},
|
||||
|
|
@ -135,7 +136,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-label flag",
|
||||
input: "23 --add-label feature,TODO,bug",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
|
|
@ -149,7 +150,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-label flag",
|
||||
input: "23 --remove-label feature,TODO,bug",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Remove: []string{"feature", "TODO", "bug"},
|
||||
|
|
@ -163,7 +164,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-project flag",
|
||||
input: "23 --add-project Cleanup,Roadmap",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
|
|
@ -179,7 +180,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-project flag",
|
||||
input: "23 --remove-project Cleanup,Roadmap",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
|
|
@ -195,7 +196,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "milestone flag",
|
||||
input: "23 --milestone GA",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
|
|
@ -205,6 +206,34 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "add label to multiple issues",
|
||||
input: "23 34 --add-label bug",
|
||||
output: EditOptions{
|
||||
SelectorArgs: []string{"23", "34"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"bug"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "interactive multiple issues",
|
||||
input: "23 34",
|
||||
output: EditOptions{
|
||||
SelectorArgs: []string{"23", "34"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"bug"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -243,7 +272,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
|
||||
assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs)
|
||||
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
|
||||
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
|
||||
})
|
||||
|
|
@ -257,12 +286,13 @@ func Test_editRun(t *testing.T) {
|
|||
httpStubs func(*testing.T, *httpmock.Registry)
|
||||
stdout string
|
||||
stderr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "non-interactive",
|
||||
input: &EditOptions{
|
||||
SelectorArg: "123",
|
||||
Interactive: false,
|
||||
SelectorArgs: []string{"123"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Title: prShared.EditableString{
|
||||
Value: "new title",
|
||||
|
|
@ -311,11 +341,176 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"456", "123"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "CleanupV2"},
|
||||
Remove: []string{"Roadmap", "RoadmapV2"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Should only be one fetch of metadata.
|
||||
mockRepoMetadata(t, reg)
|
||||
// All other queries and mutations should be doubled.
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
mockIssueNumberGet(t, reg, 456)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: heredoc.Doc(`
|
||||
https://github.com/OWNER/REPO/issue/123
|
||||
https://github.com/OWNER/REPO/issue/456
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues with fetch failures",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"123", "9999"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "CleanupV2"},
|
||||
Remove: []string{"Roadmap", "RoadmapV2"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "errors": [
|
||||
{
|
||||
"type": "NOT_FOUND",
|
||||
"message": "Could not resolve to an Issue with the number of 9999."
|
||||
}
|
||||
] }`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues with update failures",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"123", "456"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Should only be one fetch of metadata.
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "assignableUsers": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID" },
|
||||
{ "login": "MonaLisa", "id": "MONAID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "milestones": {
|
||||
"nodes": [
|
||||
{ "title": "GA", "id": "GAID" },
|
||||
{ "title": "Big One.oh", "id": "BIGONEID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
// All other queries should be doubled.
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
mockIssueNumberGet(t, reg, 456)
|
||||
// Updating 123 should succeed.
|
||||
reg.Register(
|
||||
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
|
||||
return m["id"] == "123"
|
||||
}),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "updateIssue": { "__typename": "" } } }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
// Updating 456 should fail.
|
||||
reg.Register(
|
||||
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
|
||||
return m["id"] == "456"
|
||||
}),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "errors": [ { "message": "test error" } ] }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
stdout: heredoc.Doc(`
|
||||
https://github.com/OWNER/REPO/issue/123
|
||||
`),
|
||||
stderr: `failed to update https://github.com/OWNER/REPO/issue/456:.*test error`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "interactive",
|
||||
input: &EditOptions{
|
||||
SelectorArg: "123",
|
||||
Interactive: true,
|
||||
SelectorArgs: []string{"123"},
|
||||
Interactive: true,
|
||||
FieldsToEditSurvey: func(eo *prShared.Editable) error {
|
||||
eo.Title.Edited = true
|
||||
eo.Body.Edited = true
|
||||
|
|
@ -351,38 +546,48 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
tt.httpStubs(t, reg)
|
||||
|
||||
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
|
||||
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
|
||||
|
||||
tt.input.IO = ios
|
||||
tt.input.HttpClient = httpClient
|
||||
tt.input.BaseRepo = baseRepo
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
tt.httpStubs(t, reg)
|
||||
|
||||
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
|
||||
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
|
||||
|
||||
tt.input.IO = ios
|
||||
tt.input.HttpClient = httpClient
|
||||
tt.input.BaseRepo = baseRepo
|
||||
|
||||
err := editRun(tt.input)
|
||||
assert.NoError(t, err)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
// Use regex match since mock errors and service errors will differ.
|
||||
assert.Regexp(t, tt.stderr, stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueNumberGet(nil, reg, 123)
|
||||
}
|
||||
|
||||
func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
httpmock.StringResponse(fmt.Sprintf(`
|
||||
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||
"number": 123,
|
||||
"url": "https://github.com/OWNER/REPO/issue/123",
|
||||
"id": "%[1]d",
|
||||
"number": %[1]d,
|
||||
"url": "https://github.com/OWNER/REPO/issue/%[1]d",
|
||||
"labels": {
|
||||
"nodes": [
|
||||
{ "id": "DOCSID", "name": "docs" }
|
||||
|
|
@ -393,7 +598,7 @@ func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
|
|||
{ "project": { "name": "Roadmap" } }
|
||||
], "totalCount": 1
|
||||
}
|
||||
} } } }`),
|
||||
} } } }`, number)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,25 +14,20 @@ import (
|
|||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields
|
||||
// could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError.
|
||||
func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) {
|
||||
issueNumber, baseRepo := issueMetadataFromURL(arg)
|
||||
|
||||
if issueNumber == 0 {
|
||||
var err error
|
||||
issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid issue format: %q", arg)
|
||||
}
|
||||
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if baseRepo == nil {
|
||||
var err error
|
||||
baseRepo, err = baseRepoFn()
|
||||
if err != nil {
|
||||
if baseRepo, err = baseRepoFn(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +36,70 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I
|
|||
return issue, baseRepo, err
|
||||
}
|
||||
|
||||
// IssuesFromArgWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields
|
||||
// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError.
|
||||
func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) {
|
||||
var issuesRepo ghrepo.Interface
|
||||
issueNumbers := make([]int, 0, len(args))
|
||||
|
||||
for _, arg := range args {
|
||||
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
issueNumbers = append(issueNumbers, issueNumber)
|
||||
if baseRepo == nil {
|
||||
var err error
|
||||
if baseRepo, err = baseRepoFn(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if issuesRepo == nil {
|
||||
issuesRepo = baseRepo
|
||||
continue
|
||||
}
|
||||
|
||||
if !ghrepo.IsSame(issuesRepo, baseRepo) {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"multiple issues must be in same repo: found %q, expected %q",
|
||||
ghrepo.FullName(baseRepo),
|
||||
ghrepo.FullName(issuesRepo),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
issuesChan := make(chan *api.Issue, len(args))
|
||||
g := errgroup.Group{}
|
||||
for _, num := range issueNumbers {
|
||||
issueNumber := num
|
||||
g.Go(func() error {
|
||||
issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuesChan <- issue
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := g.Wait()
|
||||
close(issuesChan)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
issues := make([]*api.Issue, 0, len(args))
|
||||
for issue := range issuesChan {
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
|
||||
return issues, issuesRepo, nil
|
||||
}
|
||||
|
||||
var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`)
|
||||
|
||||
func issueMetadataFromURL(s string) (int, ghrepo.Interface) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIssueFromArgWithFields(t *testing.T) {
|
||||
|
|
@ -195,3 +196,108 @@ func TestIssueFromArgWithFields(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesFromArgsWithFields(t *testing.T) {
|
||||
type args struct {
|
||||
baseRepoFn func() (ghrepo.Interface, error)
|
||||
selectors []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
httpStub func(*httpmock.Registry)
|
||||
wantIssues []int
|
||||
wantRepo string
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "multiple repos",
|
||||
args: args{
|
||||
selectors: []string{"1", "https://github.com/OWNER/OTHERREPO/issues/2"},
|
||||
baseRepoFn: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
},
|
||||
httpStub: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":1}
|
||||
}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":2}
|
||||
}}}`))
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "multiple issues must be in same repo",
|
||||
},
|
||||
{
|
||||
name: "multiple issues",
|
||||
args: args{
|
||||
selectors: []string{"1", "2"},
|
||||
baseRepoFn: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
},
|
||||
httpStub: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":1}
|
||||
}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":2}
|
||||
}}}`))
|
||||
},
|
||||
wantIssues: []int{1, 2},
|
||||
wantRepo: "https://github.com/OWNER/REPO",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if !tt.wantErr && len(tt.args.selectors) != len(tt.wantIssues) {
|
||||
t.Fatal("number of selectors and issues not equal")
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStub != nil {
|
||||
tt.httpStub(reg)
|
||||
}
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr)
|
||||
if issues == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErrMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
for i, issue := range issues {
|
||||
if issue.Number != tt.wantIssues[i] {
|
||||
t.Errorf("want issue #%d, got #%d", tt.wantIssues[i], issue.Number)
|
||||
}
|
||||
}
|
||||
if repo != nil {
|
||||
repoURL := ghrepo.GenerateRepoURL(repo, "")
|
||||
if repoURL != tt.wantRepo {
|
||||
t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
|
||||
}
|
||||
} else if tt.wantRepo != "" {
|
||||
t.Errorf("want repo %sw, got nil", tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
pkg/cmd/org/list/http.go
Normal file
98
pkg/cmd/org/list/http.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
)
|
||||
|
||||
type OrganizationList struct {
|
||||
Organizations []Organization
|
||||
TotalCount int
|
||||
User string
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
Login string
|
||||
}
|
||||
|
||||
func listOrgs(httpClient *http.Client, hostname string, limit int) (*OrganizationList, error) {
|
||||
type response struct {
|
||||
User struct {
|
||||
Login string
|
||||
Organizations struct {
|
||||
TotalCount int
|
||||
Nodes []Organization
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"organizations(first: $limit, after: $endCursor)"`
|
||||
}
|
||||
}
|
||||
|
||||
query := `query OrganizationList($user: String!, $limit: Int!, $endCursor: String) {
|
||||
user(login: $user) {
|
||||
login
|
||||
organizations(first: $limit, after: $endCursor) {
|
||||
totalCount
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
user, err := api.CurrentLoginName(client, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResult := OrganizationList{}
|
||||
listResult.User = user
|
||||
pageLimit := min(limit, 100)
|
||||
variables := map[string]interface{}{
|
||||
"user": user,
|
||||
}
|
||||
|
||||
loop:
|
||||
for {
|
||||
variables["limit"] = pageLimit
|
||||
var data response
|
||||
err := client.GraphQL(hostname, query, variables, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResult.TotalCount = data.User.Organizations.TotalCount
|
||||
|
||||
for _, org := range data.User.Organizations.Nodes {
|
||||
listResult.Organizations = append(listResult.Organizations, org)
|
||||
if len(listResult.Organizations) == limit {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
if data.User.Organizations.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = data.User.Organizations.PageInfo.EndCursor
|
||||
pageLimit = min(pageLimit, limit-len(listResult.Organizations))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &listResult, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
83
pkg/cmd/org/list/http_test.go
Normal file
83
pkg/cmd/org/list/http_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
)
|
||||
|
||||
func Test_listOrgs(t *testing.T) {
|
||||
type args struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
httpStub func(*testing.T, *httpmock.Registry)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
args: args{
|
||||
limit: 30,
|
||||
},
|
||||
httpStub: func(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) {
|
||||
want := map[string]interface{}{
|
||||
"user": "octocat",
|
||||
"limit": float64(30),
|
||||
}
|
||||
if !reflect.DeepEqual(vars, want) {
|
||||
t.Errorf("got GraphQL variables %#v, want %#v", vars, want)
|
||||
}
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
args: args{
|
||||
limit: 1,
|
||||
},
|
||||
httpStub: func(t *testing.T, r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) {
|
||||
want := map[string]interface{}{
|
||||
"user": "octocat",
|
||||
"limit": float64(1),
|
||||
}
|
||||
if !reflect.DeepEqual(vars, want) {
|
||||
t.Errorf("got GraphQL variables %#v, want %#v", vars, want)
|
||||
}
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStub != nil {
|
||||
tt.httpStub(t, reg)
|
||||
}
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
_, err := listOrgs(httpClient, "octocat", tt.args.limit)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("listOrgs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
pkg/cmd/org/list/list.go
Normal file
108
pkg/cmd/org/list/list.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config func() (config.Config, error)
|
||||
HttpClient func() (*http.Client, error)
|
||||
|
||||
Limit int
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
opts := ListOptions{
|
||||
IO: f.IOStreams,
|
||||
Config: f.Config,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Short: "List organizations for the authenticated user.",
|
||||
Example: heredoc.Doc(`
|
||||
# List the first 30 organizations
|
||||
$ gh org list
|
||||
|
||||
# List more organizations
|
||||
$ gh org list --limit 100
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.Limit < 1 {
|
||||
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(&opts)
|
||||
}
|
||||
return listRun(&opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of organizations to list")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
listResult, err := listOrgs(httpClient, host, opts.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err != nil {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
||||
}
|
||||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
header := listHeader(listResult.User, len(listResult.Organizations), listResult.TotalCount)
|
||||
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", header)
|
||||
}
|
||||
|
||||
table := tableprinter.New(opts.IO)
|
||||
|
||||
for _, org := range listResult.Organizations {
|
||||
table.AddField(org.Login)
|
||||
table.EndRow()
|
||||
}
|
||||
|
||||
err = table.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listHeader(user string, resultCount, totalCount int) string {
|
||||
if totalCount == 0 {
|
||||
return "There are no organizations associated with @" + user
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Showing %d of %s", resultCount, text.Pluralize(totalCount, "organization"))
|
||||
}
|
||||
237
pkg/cmd/org/list/list_test.go
Normal file
237
pkg/cmd/org/list/list_test.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants ListOptions
|
||||
wantsErr string
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
cli: "",
|
||||
wants: ListOptions{
|
||||
Limit: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
cli: "-L 101",
|
||||
wants: ListOptions{
|
||||
Limit: 101,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
cli: "123 456",
|
||||
wantsErr: "accepts at most 1 arg(s), received 2",
|
||||
},
|
||||
{
|
||||
name: "invalid limit",
|
||||
cli: "-L 0",
|
||||
wantsErr: "invalid limit: 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr != "" {
|
||||
assert.EqualError(t, err, tt.wantsErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ListOptions
|
||||
isTTY bool
|
||||
wantOut string
|
||||
}{
|
||||
{
|
||||
name: "no organizations found",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": {
|
||||
"organizations": { "nodes": [], "totalCount": 0 }
|
||||
} } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
There are no organizations associated with @octocat
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "default behavior",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
Showing 2 of 2 organizations
|
||||
|
||||
github
|
||||
cli
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
opts: ListOptions{Limit: 1, HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
Showing 1 of 2 organizations
|
||||
|
||||
github
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "nontty output",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: false,
|
||||
wantOut: heredoc.Doc(`
|
||||
github
|
||||
cli
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
|
||||
ios.SetStdoutTTY(tt.isTTY)
|
||||
ios.SetStdinTTY(tt.isTTY)
|
||||
ios.SetStderrTTY(tt.isTTY)
|
||||
|
||||
tt.opts.IO = ios
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
}
|
||||
|
||||
err := listRun(&tt.opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
24
pkg/cmd/org/org.go
Normal file
24
pkg/cmd/org/org.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
orgListCmd "github.com/cli/cli/v2/pkg/cmd/org/list"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdOrg(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "org <command>",
|
||||
Short: "Manage organizations",
|
||||
Long: "Work with Github organizations.",
|
||||
Example: heredoc.Doc(`
|
||||
$ gh org list
|
||||
`),
|
||||
GroupID: "core",
|
||||
}
|
||||
|
||||
cmdutil.AddGroup(cmd, "General commands", orgListCmd.NewCmdList(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ func (m *mergeContext) infof(format string, args ...interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Creates a new MergeConext from MergeOptions.
|
||||
// Creates a new MergeContext from MergeOptions.
|
||||
func NewMergeContext(opts *MergeOptions) (*mergeContext, error) {
|
||||
findOptions := shared.FindOptions{
|
||||
Selector: opts.SelectorArg,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
)
|
||||
|
|
@ -201,6 +202,59 @@ func (e Editable) MilestoneId() (*string, error) {
|
|||
return &m, err
|
||||
}
|
||||
|
||||
// Clone creates a mostly-shallow copy of Editable suitable for use in parallel
|
||||
// go routines. Fields that would be mutated will be copied.
|
||||
func (e *Editable) Clone() Editable {
|
||||
return Editable{
|
||||
Title: e.Title.clone(),
|
||||
Body: e.Body.clone(),
|
||||
Base: e.Base.clone(),
|
||||
Reviewers: e.Reviewers.clone(),
|
||||
Assignees: e.Assignees.clone(),
|
||||
Labels: e.Labels.clone(),
|
||||
Projects: e.Projects.clone(),
|
||||
Milestone: e.Milestone.clone(),
|
||||
// Shallow copy since no mutation.
|
||||
Metadata: e.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EditableString) clone() EditableString {
|
||||
return EditableString{
|
||||
Value: es.Value,
|
||||
Default: es.Default,
|
||||
Edited: es.Edited,
|
||||
// Shallow copies since no mutation.
|
||||
Options: es.Options,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EditableSlice) clone() EditableSlice {
|
||||
cpy := EditableSlice{
|
||||
Edited: es.Edited,
|
||||
Allowed: es.Allowed,
|
||||
// Shallow copies since no mutation.
|
||||
Options: es.Options,
|
||||
// Copy mutable string slices.
|
||||
Add: make([]string, len(es.Add)),
|
||||
Remove: make([]string, len(es.Remove)),
|
||||
Value: make([]string, len(es.Value)),
|
||||
Default: make([]string, len(es.Default)),
|
||||
}
|
||||
copy(cpy.Add, es.Add)
|
||||
copy(cpy.Remove, es.Remove)
|
||||
copy(cpy.Value, es.Value)
|
||||
copy(cpy.Default, es.Default)
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (ep *EditableProjects) clone() EditableProjects {
|
||||
return EditableProjects{
|
||||
EditableSlice: ep.EditableSlice.clone(),
|
||||
ProjectItems: ep.ProjectItems,
|
||||
}
|
||||
}
|
||||
|
||||
func EditFieldsSurvey(editable *Editable, editorCommand string) error {
|
||||
var err error
|
||||
if editable.Title.Edited {
|
||||
|
|
@ -395,6 +449,7 @@ func multiSelectSurvey(message string, defaults, options []string) ([]string, er
|
|||
Message: message,
|
||||
Options: options,
|
||||
Default: defaults,
|
||||
Filter: prompter.LatinMatchingFilter,
|
||||
}
|
||||
err := survey.AskOne(q, &results)
|
||||
return results, err
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR
|
|||
}
|
||||
}
|
||||
|
||||
// updateIssue mutation does not support ProjectsV2 so do them in a seperate request.
|
||||
// updateIssue mutation does not support ProjectsV2 so do them in a separate request.
|
||||
if options.Projects.Edited {
|
||||
wg.Go(func() error {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
statusCode == http.StatusTemporaryRedirect ||
|
||||
statusCode == http.StatusPermanentRedirect {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transfered ownership\n", cs.FailureIcon(), fullName)
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transferred ownership\n", cs.FailureIcon(), fullName)
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,11 +157,11 @@ func Test_deleteRun(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "repo transfered ownership",
|
||||
name: "repo transferred ownership",
|
||||
opts: &DeleteOptions{RepoArg: "OWNER/REPO", Confirmed: true},
|
||||
wantErr: true,
|
||||
errMsg: "SilentError",
|
||||
wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transfered ownership\n",
|
||||
wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transferred ownership\n",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", "repos/OWNER/REPO"),
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ func searchQuery(owner string, filter FilterOptions) string {
|
|||
Is: []string{filter.Visibility},
|
||||
Language: filter.Language,
|
||||
Topic: filter.Topic,
|
||||
User: owner,
|
||||
User: []string{owner},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -108,8 +111,9 @@ var HelpTopics = map[string]map[string]string{
|
|||
The %[1]s--jq%[1]s flag requires a string argument in jq query syntax, and will only print
|
||||
those JSON values which match the query. jq queries can be used to select elements from an
|
||||
array, fields from an object, create a new array, and more. The jq utility does not need
|
||||
to be installed on the system to use this formatting directive.
|
||||
To learn about jq query syntax, see: <https://stedolan.github.io/jq/manual/v1.6/>
|
||||
to be installed on the system to use this formatting directive. When connected to a terminal,
|
||||
the output is automatically pretty-printed. To learn about jq query syntax, see:
|
||||
<https://stedolan.github.io/jq/manual/v1.6/>
|
||||
|
||||
The %[1]s--template%[1]s flag requires a string argument in Go template syntax, and will only print
|
||||
those JSON values which match the query.
|
||||
|
|
@ -171,7 +175,38 @@ var HelpTopics = map[string]map[string]string{
|
|||
codercat
|
||||
cli-maintainer
|
||||
|
||||
|
||||
# --jq can be used to implement more complex filtering and output changes:
|
||||
$ bin/gh issue list --json number,title,labels --jq \
|
||||
'map(select((.labels | length) > 0)) # must have labels
|
||||
| map(.labels = (.labels | map(.name))) # show only the label names
|
||||
| .[:3] # select the first 3 results'
|
||||
[
|
||||
{
|
||||
"labels": [
|
||||
"enhancement",
|
||||
"needs triage"
|
||||
],
|
||||
"number": 123,
|
||||
"title": "A helpful contribution"
|
||||
},
|
||||
{
|
||||
"labels": [
|
||||
"help wanted",
|
||||
"docs",
|
||||
"good first issue"
|
||||
],
|
||||
"number": 125,
|
||||
"title": "Improve the docs"
|
||||
},
|
||||
{
|
||||
"labels": [
|
||||
"enhancement",
|
||||
],
|
||||
"number": 7221,
|
||||
"title": "An exciting new feature"
|
||||
}
|
||||
]
|
||||
|
||||
# using the --template flag with the hyperlink helper
|
||||
gh issue list --json title,url --template '{{range .}}{{hyperlink .url .title}}{{"\n"}}{{end}}'
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
gpgKeyCmd "github.com/cli/cli/v2/pkg/cmd/gpg-key"
|
||||
issueCmd "github.com/cli/cli/v2/pkg/cmd/issue"
|
||||
labelCmd "github.com/cli/cli/v2/pkg/cmd/label"
|
||||
orgCmd "github.com/cli/cli/v2/pkg/cmd/org"
|
||||
prCmd "github.com/cli/cli/v2/pkg/cmd/pr"
|
||||
releaseCmd "github.com/cli/cli/v2/pkg/cmd/release"
|
||||
repoCmd "github.com/cli/cli/v2/pkg/cmd/repo"
|
||||
|
|
@ -115,6 +116,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
|
||||
cmd.AddCommand(browseCmd.NewCmdBrowse(&repoResolvingCmdFactory, nil))
|
||||
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(orgCmd.NewCmdOrg(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
147
pkg/cmd/run/delete/delete.go
Normal file
147
pkg/cmd/run/delete/delete.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRunGetLimit = 10
|
||||
)
|
||||
|
||||
type DeleteOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Prompter prompter.Prompter
|
||||
Prompt bool
|
||||
RunID string
|
||||
}
|
||||
|
||||
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
|
||||
opts := &DeleteOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Prompter: f.Prompter,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete [<run-id>]",
|
||||
Short: "Delete a workflow run",
|
||||
Example: heredoc.Doc(`
|
||||
# Interactively select a run to delete
|
||||
$ gh run delete
|
||||
|
||||
# Delete a specific run
|
||||
$ gh run delete 12345
|
||||
`),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.RunID = args[0]
|
||||
} else if !opts.IO.CanPrompt() {
|
||||
return cmdutil.FlagErrorf("run ID required when not running interactively")
|
||||
} else {
|
||||
opts.Prompt = true
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return runDelete(opts)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(opts *DeleteOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
repo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine base repo: %w", err)
|
||||
}
|
||||
|
||||
runID := opts.RunID
|
||||
var run *shared.Run
|
||||
|
||||
if opts.Prompt {
|
||||
payload, err := shared.GetRuns(client, repo, nil, defaultRunGetLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get runs: %w", err)
|
||||
}
|
||||
|
||||
runs := payload.WorkflowRuns
|
||||
if len(runs) == 0 {
|
||||
return fmt.Errorf("found no runs to delete")
|
||||
}
|
||||
|
||||
runID, err = shared.SelectRun(opts.Prompter, cs, runs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range runs {
|
||||
if fmt.Sprintf("%d", r.ID) == runID {
|
||||
run = &r
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
run, err = shared.GetRun(client, repo, runID, 0)
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if httpErr.StatusCode == http.StatusNotFound {
|
||||
err = fmt.Errorf("could not find any workflow run with ID %s", opts.RunID)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = deleteWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID))
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if httpErr.StatusCode == http.StatusConflict {
|
||||
err = fmt.Errorf("cannot delete a workflow run that is completed")
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon())
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error {
|
||||
path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
|
||||
err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
200
pkg/cmd/run/delete/delete_test.go
Normal file
200
pkg/cmd/run/delete/delete_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/cmd/run/shared"
|
||||
workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
tty bool
|
||||
wants DeleteOptions
|
||||
wantsErr bool
|
||||
prompterStubs func(*prompter.PrompterMock)
|
||||
}{
|
||||
{
|
||||
name: "blank tty",
|
||||
tty: true,
|
||||
wants: DeleteOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blank nontty",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "with arg",
|
||||
cli: "1234",
|
||||
wants: DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(tt.tty)
|
||||
ios.SetStdoutTTY(tt.tty)
|
||||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *DeleteOptions
|
||||
cmd := NewCmdDelete(f, func(opts *DeleteOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *DeleteOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
prompterStubs func(*prompter.PrompterMock)
|
||||
wantErr bool
|
||||
wantOut string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "delete run",
|
||||
opts: &DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
wantErr: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
|
||||
httpmock.StatusStringResponse(204, ""))
|
||||
},
|
||||
wantOut: "✓ Request to delete workflow submitted.\n",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
opts: &DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "could not find any workflow run with ID 1234",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.StatusStringResponse(404, ""))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt",
|
||||
opts: &DeleteOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(_, _ string, opts []string) (int, error) {
|
||||
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
TotalCount: 0,
|
||||
WorkflowRuns: []shared.Run{shared.SuccessfulRun},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
|
||||
httpmock.JSONResponse(
|
||||
workflowShared.WorkflowsPayload{
|
||||
Workflows: []workflowShared.Workflow{shared.TestWorkflow},
|
||||
},
|
||||
))
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
|
||||
httpmock.StatusStringResponse(204, ""))
|
||||
},
|
||||
wantOut: "✓ Request to delete workflow submitted.\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("OWNER/REPO")
|
||||
}
|
||||
|
||||
// mock http
|
||||
reg := &httpmock.Registry{}
|
||||
tt.httpStubs(reg)
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
// mock IO
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
tt.opts.IO = ios
|
||||
|
||||
// mock prompter
|
||||
pm := &prompter.PrompterMock{}
|
||||
if tt.prompterStubs != nil {
|
||||
tt.prompterStubs(pm)
|
||||
}
|
||||
tt.opts.Prompter = pm
|
||||
|
||||
// execute test
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runDelete(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,9 @@ type ListOptions struct {
|
|||
WorkflowSelector string
|
||||
Branch string
|
||||
Actor string
|
||||
Status string
|
||||
Event string
|
||||
Created string
|
||||
|
||||
now time.Time
|
||||
}
|
||||
|
|
@ -67,8 +70,13 @@ 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")
|
||||
cmd.Flags().StringVarP(&opts.Event, "event", "e", "", "Filter runs by which `event` triggered the run")
|
||||
cmd.Flags().StringVarP(&opts.Created, "created", "", "", "Filter runs by the `date` it was created")
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +93,11 @@ func listRun(opts *ListOptions) error {
|
|||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
filters := &shared.FilterOptions{
|
||||
Branch: opts.Branch,
|
||||
Actor: opts.Actor,
|
||||
Branch: opts.Branch,
|
||||
Actor: opts.Actor,
|
||||
Status: opts.Status,
|
||||
Event: opts.Event,
|
||||
Created: opts.Created,
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ func TestNewCmdList(t *testing.T) {
|
|||
Actor: "bak1an",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cli: "--status completed",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Status: "completed",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "event",
|
||||
cli: "--event push",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Event: "push",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "created",
|
||||
cli: "--created >=2023-04-24",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Created: ">=2023-04-24",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -104,6 +128,9 @@ func TestNewCmdList(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.WorkflowSelector, gotOpts.WorkflowSelector)
|
||||
assert.Equal(t, tt.wants.Branch, gotOpts.Branch)
|
||||
assert.Equal(t, tt.wants.Actor, gotOpts.Actor)
|
||||
assert.Equal(t, tt.wants.Status, gotOpts.Status)
|
||||
assert.Equal(t, tt.wants.Event, gotOpts.Event)
|
||||
assert.Equal(t, tt.wants.Created, gotOpts.Created)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +142,7 @@ func TestListRun(t *testing.T) {
|
|||
wantErr bool
|
||||
wantOut string
|
||||
wantErrOut string
|
||||
wantErrMsg string
|
||||
stubs func(*httpmock.Registry)
|
||||
isTTY bool
|
||||
}{
|
||||
|
|
@ -335,7 +363,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "workflow selector",
|
||||
|
|
@ -376,7 +405,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "actor filter applied",
|
||||
|
|
@ -392,7 +422,62 @@ 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",
|
||||
},
|
||||
{
|
||||
name: "event filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Event: "push",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"event": []string{"push"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "created filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Created: ">=2023-04-24",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"created": []string{">=2023-04-24"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +501,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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package run
|
|||
|
||||
import (
|
||||
cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel"
|
||||
cmdDelete "github.com/cli/cli/v2/pkg/cmd/run/delete"
|
||||
cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download"
|
||||
cmdList "github.com/cli/cli/v2/pkg/cmd/run/list"
|
||||
cmdRerun "github.com/cli/cli/v2/pkg/cmd/run/rerun"
|
||||
|
|
@ -26,6 +27,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
|
|||
cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil))
|
||||
cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil))
|
||||
cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil))
|
||||
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/prompt"
|
||||
|
|
@ -44,6 +45,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 +302,9 @@ type FilterOptions struct {
|
|||
WorkflowID int64
|
||||
// avoid loading workflow name separately and use the provided one
|
||||
WorkflowName string
|
||||
Status string
|
||||
Event string
|
||||
Created string
|
||||
}
|
||||
|
||||
// GetRunsWithFilter fetches 50 runs from the API and filters them in-memory
|
||||
|
|
@ -326,6 +347,15 @@ 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))
|
||||
}
|
||||
if opts.Event != "" {
|
||||
path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event))
|
||||
}
|
||||
if opts.Created != "" {
|
||||
path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created))
|
||||
}
|
||||
}
|
||||
|
||||
var result *RunsPayload
|
||||
|
|
@ -420,6 +450,29 @@ func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, erro
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
// SelectRun prompts the user to select a run from a list of runs by using the recommended prompter interface
|
||||
func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
candidates := []string{}
|
||||
|
||||
for _, run := range runs {
|
||||
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
|
||||
candidates = append(candidates,
|
||||
// TODO truncate commit message, long ones look terrible
|
||||
fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
|
||||
}
|
||||
|
||||
selected, err := p.Select("Select a workflow run", "", candidates)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", runs[selected].ID), nil
|
||||
}
|
||||
|
||||
// TODO: this should be deprecated in favor of SelectRun
|
||||
func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
|
||||
var selected int
|
||||
now := time.Now()
|
||||
|
|
@ -449,16 +502,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
|
||||
|
|
@ -492,7 +503,7 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
|
|||
// ├── 1_stepname.txt
|
||||
// └── 2_somestepname.txt
|
||||
//
|
||||
// It iterates through the list of jobs and trys to find the matching
|
||||
// It iterates through the list of jobs and tries to find the matching
|
||||
// log in the zip file. If the matching log is found it is attached
|
||||
// to the job.
|
||||
func attachRunLog(rlz *zip.ReadCloser, jobs []shared.Job) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra.
|
|||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Hash, "hash", "", "Filter by commit hash")
|
||||
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Merge, "merge", "", "Filter on merge commits")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
|
||||
cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner")
|
||||
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility")
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func TestNewCmdCommits(t *testing.T) {
|
|||
Parent: "bbb",
|
||||
Repo: []string{"owner/repo"},
|
||||
Tree: "ccc",
|
||||
User: "owner",
|
||||
User: []string{"owner"},
|
||||
Is: []string{"public"},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -156,11 +156,11 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
|
|||
cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
|
||||
cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,11 +167,11 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr
|
|||
cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
|
||||
cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner")
|
||||
|
||||
// Pull request query qualifier flags
|
||||
cmd.Flags().StringVarP(&opts.Query.Qualifiers.Base, "base", "B", "", "Filter on base branch name")
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
|
|||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Stars, "stars", "", "Filter on `number` of stars")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Topic, "topic", nil, "Filter on topic")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Topics, "number-topics", "", "Filter on `number` of topics")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on owner")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on owner")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ func TestNewCmdRepos(t *testing.T) {
|
|||
Stars: "6",
|
||||
Topic: []string{"topic"},
|
||||
Topics: "7",
|
||||
User: "owner",
|
||||
User: []string{"owner"},
|
||||
Is: []string{"public"},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"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/spf13/cobra"
|
||||
|
|
@ -19,6 +20,7 @@ type AddOptions struct {
|
|||
|
||||
KeyFile string
|
||||
Title string
|
||||
Type string
|
||||
}
|
||||
|
||||
func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command {
|
||||
|
|
@ -49,6 +51,8 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command
|
|||
},
|
||||
}
|
||||
|
||||
typeEnums := []string{shared.AuthenticationKey, shared.SigningKey}
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Type, "type", "", shared.AuthenticationKey, typeEnums, "Type of the ssh key")
|
||||
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key")
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -79,14 +83,25 @@ func runAdd(opts *AddOptions) error {
|
|||
|
||||
hostname, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
var uploaded bool
|
||||
|
||||
if opts.Type == shared.SigningKey {
|
||||
uploaded, err = SSHSigningKeyUpload(httpClient, hostname, keyReader, opts.Title)
|
||||
} else {
|
||||
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,140 @@ 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 signing key format, not already in use",
|
||||
stdin: "ssh-ed25519 asdf",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse("[]"))
|
||||
reg.Register(
|
||||
httpmock.REST("POST", "user/ssh_signing_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: "-", Type: "signing"},
|
||||
},
|
||||
{
|
||||
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: "valid signing key format, already in use",
|
||||
stdin: "ssh-ed25519 asdf title",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_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: "-", Type: "signing"},
|
||||
},
|
||||
{
|
||||
name: "invalid key format",
|
||||
stdin: "ssh-ed25519",
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
wantErrMsg: "provided key is not in a valid format",
|
||||
opts: AddOptions{KeyFile: "-"},
|
||||
},
|
||||
{
|
||||
name: "invalid signing key format",
|
||||
stdin: "ssh-ed25519",
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
wantErrMsg: "provided key is not in a valid format",
|
||||
opts: AddOptions{KeyFile: "-", Type: "signing"},
|
||||
},
|
||||
}
|
||||
|
||||
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,26 +3,101 @@ 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,
|
||||
}
|
||||
|
||||
err = keyUpload(httpClient, url, payload)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not.
|
||||
func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) {
|
||||
url := ghinstance.RESTPrefix(hostname) + "user/ssh_signing_keys"
|
||||
|
||||
keyBytes, err := io.ReadAll(keyFile)
|
||||
if err != nil {
|
||||
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.UserSigningKeys(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": fullUserKey,
|
||||
}
|
||||
|
||||
err = keyUpload(httpClient, url, payload)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func keyUpload(httpClient *http.Client, url string, payload map[string]string) error {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
)
|
||||
|
||||
type sshKey struct {
|
||||
ID int
|
||||
Key string
|
||||
Title string
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func userKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
resource := "user/keys"
|
||||
if userHandle != "" {
|
||||
resource = fmt.Sprintf("users/%s/keys", userHandle)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return nil, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []sshKey
|
||||
err = json.Unmarshal(b, &keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"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"
|
||||
|
|
@ -54,12 +59,22 @@ func listRun(opts *ListOptions) error {
|
|||
}
|
||||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
sshKeys, err := userKeys(apiClient, host, "")
|
||||
if err != nil {
|
||||
return err
|
||||
sshAuthKeys, authKeyErr := shared.UserKeys(apiClient, host, "")
|
||||
if authKeyErr != nil {
|
||||
printError(opts.IO.ErrOut, authKeyErr)
|
||||
}
|
||||
|
||||
sshSigningKeys, signKeyErr := shared.UserSigningKeys(apiClient, host, "")
|
||||
if signKeyErr != nil {
|
||||
printError(opts.IO.ErrOut, signKeyErr)
|
||||
}
|
||||
|
||||
if authKeyErr != nil && signKeyErr != nil {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
sshKeys := append(sshAuthKeys, sshSigningKeys...)
|
||||
|
||||
if len(sshKeys) == 0 {
|
||||
return cmdutil.NewNoResultsError("no SSH keys present in the GitHub account")
|
||||
}
|
||||
|
|
@ -73,6 +88,7 @@ func listRun(opts *ListOptions) error {
|
|||
t.AddField("TITLE", nil, nil)
|
||||
t.AddField("ID", nil, nil)
|
||||
t.AddField("KEY", nil, nil)
|
||||
t.AddField("TYPE", nil, nil)
|
||||
t.AddField("ADDED", nil, nil)
|
||||
t.EndRow()
|
||||
}
|
||||
|
|
@ -85,12 +101,14 @@ func listRun(opts *ListOptions) error {
|
|||
t.AddField(sshKey.Title, nil, nil)
|
||||
t.AddField(id, nil, nil)
|
||||
t.AddField(sshKey.Key, truncateMiddle, nil)
|
||||
t.AddField(sshKey.Type, nil, nil)
|
||||
t.AddField(text.FuzzyAgoAbbr(now, sshKey.CreatedAt), nil, cs.Gray)
|
||||
} else {
|
||||
t.AddField(sshKey.Title, nil, nil)
|
||||
t.AddField(sshKey.Key, nil, nil)
|
||||
t.AddField(createdAt, nil, nil)
|
||||
t.AddField(id, nil, nil)
|
||||
t.AddField(sshKey.Type, nil, nil)
|
||||
}
|
||||
|
||||
t.EndRow()
|
||||
|
|
@ -113,3 +131,13 @@ func truncateMiddle(maxWidth int, t string) string {
|
|||
remainder := (maxWidth - len(ellipsis)) % 2
|
||||
return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:]
|
||||
}
|
||||
|
||||
func printError(w io.Writer, err error) {
|
||||
fmt.Fprintln(w, "warning: ", err)
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if msg := httpErr.ScopesSuggestion(); msg != "" {
|
||||
fmt.Fprintln(w, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func TestListRun(t *testing.T) {
|
|||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "list tty",
|
||||
name: "list authentication and signing keys; in tty",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
|
|
@ -44,19 +44,31 @@ func TestListRun(t *testing.T) {
|
|||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 321,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac Signing",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
isTTY: true,
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 1d
|
||||
hubot@Windows 5678 ssh-rsa EEEEEEEK247 1d
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 authentication 1d
|
||||
hubot@Windows 5678 ssh-rsa EEEEEEEK247 authentication 1d
|
||||
Mac Signing 321 ssh-rsa AAAABbBB123 signing 1d
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "list non-tty",
|
||||
name: "list authentication and signing keys; in non-tty",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
|
||||
|
|
@ -78,16 +90,96 @@ func TestListRun(t *testing.T) {
|
|||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 321,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac Signing",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
isTTY: false,
|
||||
wantStdout: heredoc.Doc(`
|
||||
Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234
|
||||
hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678
|
||||
Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 authentication
|
||||
hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 authentication
|
||||
Mac Signing ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 321 signing
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "only authentication ssh keys are available",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 1234,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StatusStringResponse(404, "Not Found"),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 authentication 1d
|
||||
`),
|
||||
wantStderr: heredoc.Doc(`
|
||||
warning: HTTP 404 (https://api.github.com/user/ssh_signing_keys?per_page=100)
|
||||
`),
|
||||
wantErr: false,
|
||||
isTTY: true,
|
||||
},
|
||||
{
|
||||
name: "only signing ssh keys are available",
|
||||
opts: ListOptions{
|
||||
HTTPClient: func() (*http.Client, error) {
|
||||
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`[
|
||||
{
|
||||
"id": 1234,
|
||||
"key": "ssh-rsa AAAABbBB123",
|
||||
"title": "Mac",
|
||||
"created_at": "%[1]s"
|
||||
}
|
||||
]`, createdAt.Format(time.RFC3339))),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StatusStringResponse(404, "Not Found"),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
wantStdout: heredoc.Doc(`
|
||||
TITLE ID KEY TYPE ADDED
|
||||
Mac 1234 ssh-rsa AAAABbBB123 signing 1d
|
||||
`),
|
||||
wantStderr: heredoc.Doc(`
|
||||
warning: HTTP 404 (https://api.github.com/user/keys?per_page=100)
|
||||
`),
|
||||
wantErr: false,
|
||||
isTTY: true,
|
||||
},
|
||||
{
|
||||
name: "no keys tty",
|
||||
opts: ListOptions{
|
||||
|
|
@ -97,6 +189,10 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
|
|
@ -114,6 +210,10 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.REST("GET", "user/keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "user/ssh_signing_keys"),
|
||||
httpmock.StringResponse(`[]`),
|
||||
)
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
},
|
||||
|
|
|
|||
95
pkg/cmd/ssh-key/shared/user_keys.go
Normal file
95
pkg/cmd/ssh-key/shared/user_keys.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthenticationKey = "authentication"
|
||||
SigningKey = "signing"
|
||||
)
|
||||
|
||||
type sshKey struct {
|
||||
ID int
|
||||
Key string
|
||||
Title string
|
||||
Type string
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
resource := "user/keys"
|
||||
if userHandle != "" {
|
||||
resource = fmt.Sprintf("users/%s/keys", userHandle)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
|
||||
|
||||
keys, err := getUserKeys(httpClient, url)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i].Type = AuthenticationKey
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) {
|
||||
resource := "user/ssh_signing_keys"
|
||||
if userHandle != "" {
|
||||
resource = fmt.Sprintf("users/%s/ssh_signing_keys", userHandle)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
|
||||
|
||||
keys, err := getUserKeys(httpClient, url)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < len(keys); i++ {
|
||||
keys[i].Type = SigningKey
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func getUserKeys(httpClient *http.Client, url string) ([]sshKey, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return nil, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []sshKey
|
||||
err = json.Unmarshal(b, &keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
|
@ -429,7 +429,7 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
if err != nil {
|
||||
var gqlErrResponse api.GraphQLError
|
||||
if errors.As(err, &gqlErrResponse) {
|
||||
gqlErrors := make([]ghAPI.GQLErrorItem, 0, len(gqlErrResponse.Errors))
|
||||
gqlErrors := make([]ghAPI.GraphQLErrorItem, 0, len(gqlErrResponse.Errors))
|
||||
// Exclude any FORBIDDEN errors and show status for what we can.
|
||||
for _, gqlErr := range gqlErrResponse.Errors {
|
||||
if gqlErr.Type == "FORBIDDEN" {
|
||||
|
|
@ -442,7 +442,7 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
err = nil
|
||||
} else {
|
||||
err = api.GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
GraphQLError: &ghAPI.GraphQLError{
|
||||
Errors: gqlErrors,
|
||||
},
|
||||
}
|
||||
|
|
@ -467,7 +467,7 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
case "PullRequest":
|
||||
prs = append(prs, *node)
|
||||
default:
|
||||
return fmt.Errorf("unkown Assignments type: %q", node.Type)
|
||||
return fmt.Errorf("unknown Assignments type: %q", node.Type)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "|"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/jsoncolor"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/go-gh/pkg/jq"
|
||||
"github.com/cli/go-gh/pkg/template"
|
||||
"github.com/cli/go-gh/v2/pkg/jq"
|
||||
"github.com/cli/go-gh/v2/pkg/template"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
|
@ -138,7 +138,13 @@ func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
|
|||
|
||||
w := ios.Out
|
||||
if e.filter != "" {
|
||||
return jq.Evaluate(&buf, w, e.filter)
|
||||
indent := ""
|
||||
if ios.IsStdoutTTY() {
|
||||
indent = " "
|
||||
}
|
||||
if err := jq.EvaluateFormatted(&buf, w, e.filter, indent, ios.ColorEnabled()); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if e.template != "" {
|
||||
t := template.New(w, ios.TerminalWidth(), ios.ColorEnabled())
|
||||
if err := t.Parse(e.template); err != nil {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
args args
|
||||
wantW string
|
||||
wantErr bool
|
||||
istty bool
|
||||
}{
|
||||
{
|
||||
name: "regular JSON output",
|
||||
|
|
@ -135,6 +136,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
},
|
||||
wantW: "{\"name\":\"hubot\"}\n",
|
||||
wantErr: false,
|
||||
istty: false,
|
||||
},
|
||||
{
|
||||
name: "call ExportData",
|
||||
|
|
@ -144,6 +146,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
},
|
||||
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
|
||||
wantErr: false,
|
||||
istty: false,
|
||||
},
|
||||
{
|
||||
name: "recursively call ExportData",
|
||||
|
|
@ -156,6 +159,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
},
|
||||
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
|
||||
wantErr: false,
|
||||
istty: false,
|
||||
},
|
||||
{
|
||||
name: "with jq filter",
|
||||
|
|
@ -165,6 +169,17 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
},
|
||||
wantW: "hubot\n",
|
||||
wantErr: false,
|
||||
istty: false,
|
||||
},
|
||||
{
|
||||
name: "with jq filter pretty printing",
|
||||
exporter: exportFormat{filter: "."},
|
||||
args: args{
|
||||
data: map[string]string{"name": "hubot"},
|
||||
},
|
||||
wantW: "{\n \"name\": \"hubot\"\n}\n",
|
||||
wantErr: false,
|
||||
istty: true,
|
||||
},
|
||||
{
|
||||
name: "with Go template",
|
||||
|
|
@ -174,11 +189,13 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
},
|
||||
wantW: "hubot",
|
||||
wantErr: false,
|
||||
istty: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, w, _ := iostreams.Test()
|
||||
io.SetStdoutTTY(tt.istty)
|
||||
if err := tt.exporter.Write(io, tt.args.data); (err != nil) != tt.wantErr {
|
||||
t.Errorf("exportFormat.Write() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -57,6 +57,33 @@ func GraphQL(q string) Matcher {
|
|||
}
|
||||
}
|
||||
|
||||
func GraphQLMutationMatcher(q string, cb func(map[string]interface{}) bool) Matcher {
|
||||
re := regexp.MustCompile(q)
|
||||
|
||||
return func(req *http.Request) bool {
|
||||
if !strings.EqualFold(req.Method, "POST") {
|
||||
return false
|
||||
}
|
||||
if req.URL.Path != "/graphql" && req.URL.Path != "/api/graphql" {
|
||||
return false
|
||||
}
|
||||
|
||||
var bodyData struct {
|
||||
Query string
|
||||
Variables struct {
|
||||
Input map[string]interface{}
|
||||
}
|
||||
}
|
||||
_ = decodeJSONBody(req, &bodyData)
|
||||
|
||||
if re.MatchString(bodyData.Query) {
|
||||
return cb(bodyData.Variables.Input)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func QueryMatcher(method string, path string, query url.Values) Matcher {
|
||||
return func(req *http.Request) bool {
|
||||
if !REST(method, path)(req) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
ghTerm "github.com/cli/go-gh/pkg/term"
|
||||
ghTerm "github.com/cli/go-gh/v2/pkg/term"
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/google/shlex"
|
||||
"github.com/mattn/go-colorable"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func TestConnect(t *testing.T) {
|
|||
joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) {
|
||||
var joinWorkspaceReq joinWorkspaceArgs
|
||||
if err := json.Unmarshal(*req.Params, &joinWorkspaceReq); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling req: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling req: %w", err)
|
||||
}
|
||||
if joinWorkspaceReq.ID != opts.SessionID {
|
||||
return nil, errors.New("connection session id does not match")
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func (s *Session) WaitForPortNotification(ctx context.Context, port int, notifTy
|
|||
notification := new(PortNotification)
|
||||
if err := json.Unmarshal(*req.Params, ¬ification); err != nil {
|
||||
select {
|
||||
case errCh <- fmt.Errorf("error unmarshaling notification: %w", err):
|
||||
case errCh <- fmt.Errorf("error unmarshalling notification: %w", err):
|
||||
default:
|
||||
}
|
||||
return
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func TestServerStartSharing(t *testing.T) {
|
|||
startSharing := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) {
|
||||
var args []interface{}
|
||||
if err := json.Unmarshal(*req.Params, &args); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling request: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling request: %w", err)
|
||||
}
|
||||
if len(args) < 3 {
|
||||
return nil, errors.New("not enough arguments to start sharing")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package markdown
|
|||
|
||||
import (
|
||||
"github.com/charmbracelet/glamour"
|
||||
ghMarkdown "github.com/cli/go-gh/pkg/markdown"
|
||||
ghMarkdown "github.com/cli/go-gh/v2/pkg/markdown"
|
||||
)
|
||||
|
||||
func WithoutIndentation() glamour.TermRendererOption {
|
||||
|
|
@ -10,7 +10,7 @@ func WithoutIndentation() glamour.TermRendererOption {
|
|||
}
|
||||
|
||||
// WithoutWrap is a rendering option that set the character limit for soft
|
||||
// wraping the markdown rendering. There is a max limit of 120 characters.
|
||||
// wrapping the markdown rendering. There is a max limit of 120 characters.
|
||||
// If 0 is passed then wrapping is disabled.
|
||||
func WithWrap(w int) glamour.TermRendererOption {
|
||||
if w > 120 {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ type Qualifiers struct {
|
|||
Tree string
|
||||
Type string
|
||||
Updated string
|
||||
User string
|
||||
User []string
|
||||
}
|
||||
|
||||
func (q Query) String() string {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue