diff --git a/cmd/gh/main.go b/cmd/gh/main.go index a0eba09a5..e20452808 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -163,12 +163,19 @@ func main() { newRelease := <-updateMessageChan if newRelease != nil { - ghExe, _ := os.Executable() + isHomebrew := false + if ghExe, err := os.Executable(); err == nil { + isHomebrew = isUnderHomebrew(ghExe) + } + if isHomebrew && isRecentRelease(newRelease.PublishedAt) { + // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core + return + } fmt.Fprintf(stderr, "\n\n%s %s → %s\n", ansi.Color("A new release of gh is available:", "yellow"), ansi.Color(buildVersion, "cyan"), ansi.Color(newRelease.Version, "cyan")) - if suggestBrewUpgrade(newRelease, ghExe) { + if isHomebrew { fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh") } fmt.Fprintf(stderr, "%s\n\n", @@ -265,13 +272,12 @@ func apiVerboseLog() api.ClientOption { return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize) } -// Suggest to `brew upgrade gh` only if gh was found under homebrew prefix and when the release was -// published over 24h ago, allowing homebrew-core ample time to merge the formula bump. -func suggestBrewUpgrade(rel *update.ReleaseInfo, ghBinary string) bool { - if rel.PublishedAt.IsZero() || time.Since(rel.PublishedAt) < time.Duration(time.Hour*24) { - return false - } +func isRecentRelease(publishedAt time.Time) bool { + return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24 +} +// Check whether the gh binary was found under the Homebrew prefix +func isUnderHomebrew(ghBinary string) bool { brewExe, err := safeexec.LookPath("brew") if err != nil { return false diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index c62cef3a8..4f80afbe7 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -11,8 +11,10 @@ import ( "github.com/spf13/cobra" ) +const tokenUser = "x-access-token" + type config interface { - Get(string, string) (string, error) + GetWithSource(string, string) (string, string, error) } type CredentialOptions struct { @@ -98,13 +100,19 @@ func helperRun(opts *CredentialOptions) error { return err } - gotUser, _ := cfg.Get(wants["host"], "user") - gotToken, _ := cfg.Get(wants["host"], "oauth_token") + var gotUser string + gotToken, source, _ := cfg.GetWithSource(wants["host"], "oauth_token") + if strings.HasSuffix(source, "_TOKEN") { + gotUser = tokenUser + } else { + gotUser, _, _ = cfg.GetWithSource(wants["host"], "user") + } + if gotUser == "" || gotToken == "" { return cmdutil.SilentError } - if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) { + if wants["username"] != "" && gotUser != tokenUser && !strings.EqualFold(wants["username"], gotUser) { return cmdutil.SilentError } diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go index 336d4ef34..c8f449526 100644 --- a/pkg/cmd/auth/gitcredential/helper_test.go +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -10,8 +10,8 @@ import ( type tinyConfig map[string]string -func (c tinyConfig) Get(host, key string) (string, error) { - return c[fmt.Sprintf("%s:%s", host, key)], nil +func (c tinyConfig) GetWithSource(host, key string) (string, string, error) { + return c[fmt.Sprintf("%s:%s", host, key)], c["_source"], nil } func Test_helperRun(t *testing.T) { @@ -29,6 +29,7 @@ func Test_helperRun(t *testing.T) { Operation: "get", Config: func() (config, error) { return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", "example.com:user": "monalisa", "example.com:oauth_token": "OTOKEN", }, nil @@ -53,6 +54,7 @@ func Test_helperRun(t *testing.T) { Operation: "get", Config: func() (config, error) { return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", "example.com:user": "monalisa", "example.com:oauth_token": "OTOKEN", }, nil @@ -78,6 +80,7 @@ func Test_helperRun(t *testing.T) { Operation: "get", Config: func() (config, error) { return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", "example.com:user": "monalisa", "example.com:oauth_token": "OTOKEN", }, nil @@ -101,6 +104,7 @@ func Test_helperRun(t *testing.T) { Operation: "get", Config: func() (config, error) { return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", "example.com:user": "monalisa", }, nil }, @@ -119,6 +123,7 @@ func Test_helperRun(t *testing.T) { Operation: "get", Config: func() (config, error) { return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", "example.com:user": "monalisa", "example.com:oauth_token": "OTOKEN", }, nil @@ -133,6 +138,31 @@ func Test_helperRun(t *testing.T) { wantStdout: "", wantStderr: "", }, + { + name: "token from env", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "_source": "GITHUB_ENTERPRISE_TOKEN", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + username=hubot + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=x-access-token + password=OTOKEN + `), + wantStderr: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index fbff9882e..6f75a784f 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/authflow" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" ) @@ -125,7 +126,7 @@ func Login(opts *LoginOptions) error { fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` Tip: you can generate a Personal Access Token here https://%s/settings/tokens The minimum required scopes are %s. - `, hostname, scopesSentence(minimumScopes))) + `, hostname, scopesSentence(minimumScopes, ghinstance.IsEnterprise(hostname)))) err := prompt.SurveyAskOne(&survey.Password{ Message: "Paste your authentication token:", @@ -193,11 +194,14 @@ func Login(opts *LoginOptions) error { return nil } -func scopesSentence(scopes []string) string { +func scopesSentence(scopes []string, isEnterprise bool) string { quoted := make([]string, len(scopes)) for i, s := range scopes { quoted[i] = fmt.Sprintf("'%s'", s) + if s == "workflow" && isEnterprise { + // remove when GHE 2.x reaches EOL + quoted[i] += " (GHE 3.0+)" + } } - // TODO: insert an "and" before the last element return strings.Join(quoted, ", ") } diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index c37d89b46..521616dfa 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -101,3 +101,55 @@ func TestLogin_ssh(t *testing.T) { assert.Equal(t, "ATOKEN", cfg["example.com:oauth_token"]) assert.Equal(t, "ssh", cfg["example.com:git_protocol"]) } + +func Test_scopesSentence(t *testing.T) { + type args struct { + scopes []string + isEnterprise bool + } + tests := []struct { + name string + args args + want string + }{ + { + name: "basic scopes", + args: args{ + scopes: []string{"repo", "read:org"}, + isEnterprise: false, + }, + want: "'repo', 'read:org'", + }, + { + name: "empty", + args: args{ + scopes: []string(nil), + isEnterprise: false, + }, + want: "", + }, + { + name: "workflow scope for dotcom", + args: args{ + scopes: []string{"repo", "workflow"}, + isEnterprise: false, + }, + want: "'repo', 'workflow'", + }, + { + name: "workflow scope for GHE", + args: args{ + scopes: []string{"repo", "workflow"}, + isEnterprise: true, + }, + want: "'repo', 'workflow' (GHE 3.0+)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := scopesSentence(tt.args.scopes, tt.args.isEnterprise); got != tt.want { + t.Errorf("scopesSentence() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/auth/shared/oauth_scopes.go b/pkg/cmd/auth/shared/oauth_scopes.go index c99b5543d..20dbf9a83 100644 --- a/pkg/cmd/auth/shared/oauth_scopes.go +++ b/pkg/cmd/auth/shared/oauth_scopes.go @@ -40,7 +40,7 @@ func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error { return err } - req.Header.Set("Autorization", "token "+authToken) + req.Header.Set("Authorization", "token "+authToken) res, err := httpClient.Do(req) if err != nil { diff --git a/pkg/cmd/auth/shared/oauth_scopes_test.go b/pkg/cmd/auth/shared/oauth_scopes_test.go index 3b9766a95..0f6bd9f32 100644 --- a/pkg/cmd/auth/shared/oauth_scopes_test.go +++ b/pkg/cmd/auth/shared/oauth_scopes_test.go @@ -52,7 +52,9 @@ func Test_HasMinimumScopes(t *testing.T) { fakehttp := &httpmock.Registry{} defer fakehttp.Verify(t) + var gotAuthorization string fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) { + gotAuthorization = req.Header.Get("authorization") return &http.Response{ Request: req, StatusCode: 200, @@ -70,6 +72,7 @@ func Test_HasMinimumScopes(t *testing.T) { } else { assert.NoError(t, err) } + assert.Equal(t, gotAuthorization, "token ATOKEN") }) } diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go index 54f745030..674c33ad6 100644 --- a/pkg/cmd/gist/delete/delete.go +++ b/pkg/cmd/gist/delete/delete.go @@ -29,7 +29,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "delete { | }", Short: "Delete a gist", - Args: cmdutil.MinimumArgs(1, "cannot delete: gist argument required"), + Args: cmdutil.ExactArgs(1, "cannot delete: gist argument required"), RunE: func(c *cobra.Command, args []string) error { opts.Selector = args[0] if runF != nil { diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 7cba14800..a8fa4d683 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -49,7 +49,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "edit { | }", Short: "Edit one of your gists", - Args: cmdutil.MinimumArgs(1, "cannot edit: gist argument required"), + Args: cmdutil.ExactArgs(1, "cannot edit: gist argument required"), RunE: func(c *cobra.Command, args []string) error { opts.Selector = args[0] diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index d2cd84304..f7f73bb28 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -46,7 +46,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr cmd := &cobra.Command{ Use: "checkout { | | }", Short: "Check out a pull request in git", - Args: cmdutil.MinimumArgs(1, "argument required"), + Args: cmdutil.ExactArgs(1, "argument required"), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 6c1592b4c..3fc1c8f10 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -70,6 +70,9 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman if flags.Changed("body") { opts.Editable.Body.Edited = true } + if flags.Changed("base") { + opts.Editable.Base.Edited = true + } if flags.Changed("add-reviewer") || flags.Changed("remove-reviewer") { opts.Editable.Reviewers.Edited = true } @@ -104,6 +107,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.") cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.") + cmd.Flags().StringVarP(&opts.Editable.Base.Value, "base", "B", "", "Change the base `branch` for this pull request") cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Add, "add-reviewer", nil, "Add reviewers by their `login`.") cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`.") cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.") @@ -133,6 +137,7 @@ func editRun(opts *EditOptions) error { editable.Reviewers.Allowed = true editable.Title.Default = pr.Title editable.Body.Default = pr.Body + editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() editable.Assignees.Default = pr.Assignees.Logins() editable.Labels.Default = pr.Labels.Names() @@ -203,6 +208,9 @@ func updatePullRequest(client *api.Client, repo ghrepo.Interface, id string, edi return err } params.MilestoneID = ghId(milestoneId) + if editable.Base.Edited { + params.BaseRefName = ghString(&editable.Base.Value) + } err = api.UpdatePullRequest(client, repo, params) if err != nil { return err diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 35d8278dc..da328ba5b 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -65,6 +65,20 @@ func TestNewCmdEdit(t *testing.T) { }, wantsErr: false, }, + { + name: "base flag", + input: "23 --base base-branch-name", + output: EditOptions{ + SelectorArg: "23", + Editable: shared.Editable{ + Base: shared.EditableString{ + Value: "base-branch-name", + Edited: true, + }, + }, + }, + wantsErr: false, + }, { name: "add-reviewer flag", input: "23 --add-reviewer monalisa,owner/core", @@ -254,6 +268,10 @@ func Test_editRun(t *testing.T) { Value: "new body", Edited: true, }, + Base: shared.EditableString{ + Value: "base-branch-name", + Edited: true, + }, Reviewers: shared.EditableSlice{ Add: []string{"OWNER/core", "OWNER/external", "monalisa", "hubot"}, Remove: []string{"dependabot"}, @@ -303,6 +321,10 @@ func Test_editRun(t *testing.T) { Value: "new body", Edited: true, }, + Base: shared.EditableString{ + Value: "base-branch-name", + Edited: true, + }, Assignees: shared.EditableSlice{ Add: []string{"monalisa", "hubot"}, Remove: []string{"octocat"}, diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 16faca4de..abcfc9b43 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -14,6 +14,7 @@ import ( type Editable struct { Title EditableString Body EditableString + Base EditableString Reviewers EditableSlice Assignees EditableSlice Labels EditableSlice @@ -42,6 +43,7 @@ type EditableSlice struct { func (e Editable) Dirty() bool { return e.Title.Edited || e.Body.Edited || + e.Base.Edited || e.Reviewers.Edited || e.Assignees.Edited || e.Labels.Edited || diff --git a/pkg/cmdutil/args.go b/pkg/cmdutil/args.go index 65f3ade51..ee9c5e350 100644 --- a/pkg/cmdutil/args.go +++ b/pkg/cmdutil/args.go @@ -21,6 +21,21 @@ func MinimumArgs(n int, msg string) cobra.PositionalArgs { } } +func ExactArgs(n int, msg string) cobra.PositionalArgs { + + return func(cmd *cobra.Command, args []string) error { + if len(args) > n { + return &FlagError{Err: errors.New("too many arguments")} + } + + if len(args) < n { + return &FlagError{Err: errors.New(msg)} + } + + return nil + } +} + func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error { if len(args) < 1 { return nil