diff --git a/acceptance/pr_test.go b/acceptance/pr_test.go new file mode 100644 index 000000000..38f3e1f79 --- /dev/null +++ b/acceptance/pr_test.go @@ -0,0 +1,85 @@ +package acceptance_test + +import ( + "os" + "path" + "strings" + "testing" + + "math/rand" + + "github.com/cli/cli/v2/internal/ghcmd" + "github.com/rogpeppe/go-internal/testscript" +) + +func ghMain() int { + return int(ghcmd.Main()) +} + +func TestMain(m *testing.M) { + os.Exit(testscript.RunMain(m, map[string]func() int{ + "gh": ghMain, + })) +} + +func TestPullRequests(t *testing.T) { + testscript.Run(t, params("pr")) +} + +func params(dir string) testscript.Params { + return testscript.Params{ + Dir: path.Join("testdata", dir), + Files: []string{}, + Setup: sharedSetup, + Cmds: sharedCmds, + RequireExplicitExec: true, + RequireUniqueNames: true, + } +} + +var sharedSetup = func(ts *testscript.Env) error { + scriptName, ok := extractScriptName(ts.Vars) + if !ok { + ts.T().Fatal("script name not found") + } + ts.Setenv("SCRIPT_NAME", scriptName) + + ts.Setenv("HOME", ts.Cd) + ts.Setenv("GH_CONFIG_DIR", ts.Cd) + + ts.Setenv("GH_TOKEN", os.Getenv("GH_TOKEN")) + + ts.Setenv("ORG", os.Getenv("GH_ACCEPTANCE_ORG")) + + ts.Setenv("RANDOM_STRING", randomString(10)) + return nil +} + +var sharedCmds = map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "defer": func(ts *testscript.TestScript, neg bool, args []string) { + ts.Defer(func() { + if err := ts.Exec(args[0], args[1:]...); err != nil { + ts.Fatalf("deferred command failed: %v", err) + } + }) + }} + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randomString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func extractScriptName(vars []string) (string, bool) { + for _, kv := range vars { + if strings.HasPrefix(kv, "WORK=") { + v := strings.Split(kv, "=")[1] + return strings.CutPrefix(path.Base(v), "script-") + } + } + return "", false +} diff --git a/acceptance/testdata/pr/pr-create-basic.txt b/acceptance/testdata/pr/pr-create-basic.txt new file mode 100644 index 000000000..46d1de990 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-basic.txt @@ -0,0 +1,24 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# Check the PR is indeed created +exec gh pr list +stdout 'Feature Title' diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 0b9af0215..e167bc6f4 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -1,276 +1,12 @@ package main import ( - "context" - "errors" - "fmt" - "io" - "net" "os" - "os/exec" - "path/filepath" - "strings" - "time" - surveyCore "github.com/AlecAivazis/survey/v2/core" - "github.com/AlecAivazis/survey/v2/terminal" - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/build" - "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/config/migration" - "github.com/cli/cli/v2/internal/update" - "github.com/cli/cli/v2/pkg/cmd/factory" - "github.com/cli/cli/v2/pkg/cmd/root" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/utils" - "github.com/cli/safeexec" - "github.com/mattn/go-isatty" - "github.com/mgutz/ansi" - "github.com/spf13/cobra" -) - -var updaterEnabled = "" - -type exitCode int - -const ( - exitOK exitCode = 0 - exitError exitCode = 1 - exitCancel exitCode = 2 - exitAuth exitCode = 4 - exitPending exitCode = 8 + "github.com/cli/cli/v2/internal/ghcmd" ) func main() { - code := mainRun() + code := ghcmd.Main() os.Exit(int(code)) } - -func mainRun() exitCode { - buildDate := build.Date - buildVersion := build.Version - hasDebug, _ := utils.IsDebugEnabled() - - cmdFactory := factory.New(buildVersion) - stderr := cmdFactory.IOStreams.ErrOut - - ctx := context.Background() - - if cfg, err := cmdFactory.Config(); err == nil { - var m migration.MultiAccount - if err := cfg.Migrate(m); err != nil { - fmt.Fprintln(stderr, err) - return exitError - } - } - - updateCtx, updateCancel := context.WithCancel(ctx) - defer updateCancel() - updateMessageChan := make(chan *update.ReleaseInfo) - go func() { - rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion) - if err != nil && hasDebug { - fmt.Fprintf(stderr, "warning: checking for update failed: %v", err) - } - updateMessageChan <- rel - }() - - if !cmdFactory.IOStreams.ColorEnabled() { - surveyCore.DisableColor = true - ansi.DisableColors(true) - } else { - // override survey's poor choice of color - surveyCore.TemplateFuncsWithColor["color"] = func(style string) string { - switch style { - case "white": - return ansi.ColorCode("default") - default: - return ansi.ColorCode(style) - } - } - } - - // Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a - // terminal. With this, a user can clone a repo (or take other actions) directly from explorer. - if len(os.Args) > 1 && os.Args[1] != "" { - cobra.MousetrapHelpText = "" - } - - rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) - if err != nil { - fmt.Fprintf(stderr, "failed to create root command: %s\n", err) - return exitError - } - - expandedArgs := []string{} - if len(os.Args) > 0 { - expandedArgs = os.Args[1:] - } - - // translate `gh help ` to `gh --help` for extensions. - if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) { - expandedArgs = expandedArgs[1:] - expandedArgs = append(expandedArgs, "--help") - } - - rootCmd.SetArgs(expandedArgs) - - if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil { - var pagerPipeError *iostreams.ErrClosedPagerPipe - var noResultsError cmdutil.NoResultsError - var extError *root.ExternalCommandExitError - var authError *root.AuthError - if err == cmdutil.SilentError { - return exitError - } else if err == cmdutil.PendingError { - return exitPending - } else if cmdutil.IsUserCancellation(err) { - if errors.Is(err, terminal.InterruptErr) { - // ensure the next shell prompt will start on its own line - fmt.Fprint(stderr, "\n") - } - return exitCancel - } else if errors.As(err, &authError) { - return exitAuth - } else if errors.As(err, &pagerPipeError) { - // ignore the error raised when piping to a closed pager - return exitOK - } else if errors.As(err, &noResultsError) { - if cmdFactory.IOStreams.IsStdoutTTY() { - fmt.Fprintln(stderr, noResultsError.Error()) - } - // no results is not a command failure - return exitOK - } else if errors.As(err, &extError) { - // pass on exit codes from extensions and shell aliases - return exitCode(extError.ExitCode()) - } - - printError(stderr, err, cmd, hasDebug) - - if strings.Contains(err.Error(), "Incorrect function") { - fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.") - fmt.Fprintln(stderr, "To learn about workarounds for this error, run: gh help mintty") - return exitError - } - - var httpErr api.HTTPError - if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { - fmt.Fprintln(stderr, "Try authenticating with: gh auth login") - } else if u := factory.SSOURL(); u != "" { - // handles organization SAML enforcement error - fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) - } else if msg := httpErr.ScopesSuggestion(); msg != "" { - fmt.Fprintln(stderr, msg) - } - - return exitError - } - if root.HasFailed() { - return exitError - } - - updateCancel() // if the update checker hasn't completed by now, abort it - newRelease := <-updateMessageChan - if newRelease != nil { - isHomebrew := isUnderHomebrew(cmdFactory.Executable()) - if isHomebrew && isRecentRelease(newRelease.PublishedAt) { - // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core - return exitOK - } - fmt.Fprintf(stderr, "\n\n%s %s → %s\n", - ansi.Color("A new release of gh is available:", "yellow"), - ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"), - ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan")) - if isHomebrew { - fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh") - } - fmt.Fprintf(stderr, "%s\n\n", - ansi.Color(newRelease.URL, "yellow")) - } - - return exitOK -} - -// isExtensionCommand returns true if args resolve to an extension command. -func isExtensionCommand(rootCmd *cobra.Command, args []string) bool { - c, _, err := rootCmd.Find(args) - return err == nil && c != nil && c.GroupID == "extension" -} - -func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { - var dnsError *net.DNSError - if errors.As(err, &dnsError) { - fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name) - if debug { - fmt.Fprintln(out, dnsError) - } - fmt.Fprintln(out, "check your internet connection or https://githubstatus.com") - return - } - - fmt.Fprintln(out, err) - - var flagError *cmdutil.FlagError - if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") { - if !strings.HasSuffix(err.Error(), "\n") { - fmt.Fprintln(out) - } - fmt.Fprintln(out, cmd.UsageString()) - } -} - -func shouldCheckForUpdate() bool { - if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" { - return false - } - if os.Getenv("CODESPACES") != "" { - return false - } - return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr) -} - -func isTerminal(f *os.File) bool { - return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) -} - -// based on https://github.com/watson/ci-info/blob/HEAD/index.js -func isCI() bool { - return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity - os.Getenv("RUN_ID") != "" // TaskCluster, dsari -} - -func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { - if !shouldCheckForUpdate() { - return nil, nil - } - httpClient, err := f.HttpClient() - if err != nil { - return nil, err - } - repo := updaterEnabled - stateFilePath := filepath.Join(config.StateDir(), "state.yml") - return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion) -} - -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 - } - - brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output() - if err != nil { - return false - } - - brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) - return strings.HasPrefix(ghBinary, brewBinPrefix) -} diff --git a/go.mod b/go.mod index e638459fa..42e3c84e6 100644 --- a/go.mod +++ b/go.mod @@ -125,6 +125,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.0.1 // indirect + github.com/rogpeppe/go-internal v1.13.1 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -160,6 +161,7 @@ require ( golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/sys v0.26.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index d1a85b60e..9a1fc23ee 100644 --- a/go.sum +++ b/go.sum @@ -366,8 +366,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ= github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= @@ -533,8 +533,8 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.172.0 h1:/1OcMZGPmW1rX2LCu2CmGUD1KXK1+pfzxotxyRUCCdk= google.golang.org/api v0.172.0/go.mod h1:+fJZq6QXWfa9pXhnIzsjx4yI22d4aI9ZpLb58gvXjis= diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go new file mode 100644 index 000000000..d5a674184 --- /dev/null +++ b/internal/ghcmd/cmd.go @@ -0,0 +1,271 @@ +package ghcmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + surveyCore "github.com/AlecAivazis/survey/v2/core" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/cmd/root" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" + "github.com/cli/safeexec" + "github.com/mattn/go-isatty" + "github.com/mgutz/ansi" + "github.com/spf13/cobra" +) + +var updaterEnabled = "" + +type exitCode int + +const ( + exitOK exitCode = 0 + exitError exitCode = 1 + exitCancel exitCode = 2 + exitAuth exitCode = 4 + exitPending exitCode = 8 +) + +func Main() exitCode { + buildDate := build.Date + buildVersion := build.Version + hasDebug, _ := utils.IsDebugEnabled() + + cmdFactory := factory.New(buildVersion) + stderr := cmdFactory.IOStreams.ErrOut + + ctx := context.Background() + + if cfg, err := cmdFactory.Config(); err == nil { + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { + fmt.Fprintln(stderr, err) + return exitError + } + } + + updateCtx, updateCancel := context.WithCancel(ctx) + defer updateCancel() + updateMessageChan := make(chan *update.ReleaseInfo) + go func() { + rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion) + if err != nil && hasDebug { + fmt.Fprintf(stderr, "warning: checking for update failed: %v", err) + } + updateMessageChan <- rel + }() + + if !cmdFactory.IOStreams.ColorEnabled() { + surveyCore.DisableColor = true + ansi.DisableColors(true) + } else { + // override survey's poor choice of color + surveyCore.TemplateFuncsWithColor["color"] = func(style string) string { + switch style { + case "white": + return ansi.ColorCode("default") + default: + return ansi.ColorCode(style) + } + } + } + + // Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a + // terminal. With this, a user can clone a repo (or take other actions) directly from explorer. + if len(os.Args) > 1 && os.Args[1] != "" { + cobra.MousetrapHelpText = "" + } + + rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) + if err != nil { + fmt.Fprintf(stderr, "failed to create root command: %s\n", err) + return exitError + } + + expandedArgs := []string{} + if len(os.Args) > 0 { + expandedArgs = os.Args[1:] + } + + // translate `gh help ` to `gh --help` for extensions. + if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) { + expandedArgs = expandedArgs[1:] + expandedArgs = append(expandedArgs, "--help") + } + + rootCmd.SetArgs(expandedArgs) + + if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil { + var pagerPipeError *iostreams.ErrClosedPagerPipe + var noResultsError cmdutil.NoResultsError + var extError *root.ExternalCommandExitError + var authError *root.AuthError + if err == cmdutil.SilentError { + return exitError + } else if err == cmdutil.PendingError { + return exitPending + } else if cmdutil.IsUserCancellation(err) { + if errors.Is(err, terminal.InterruptErr) { + // ensure the next shell prompt will start on its own line + fmt.Fprint(stderr, "\n") + } + return exitCancel + } else if errors.As(err, &authError) { + return exitAuth + } else if errors.As(err, &pagerPipeError) { + // ignore the error raised when piping to a closed pager + return exitOK + } else if errors.As(err, &noResultsError) { + if cmdFactory.IOStreams.IsStdoutTTY() { + fmt.Fprintln(stderr, noResultsError.Error()) + } + // no results is not a command failure + return exitOK + } else if errors.As(err, &extError) { + // pass on exit codes from extensions and shell aliases + return exitCode(extError.ExitCode()) + } + + printError(stderr, err, cmd, hasDebug) + + if strings.Contains(err.Error(), "Incorrect function") { + fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.") + fmt.Fprintln(stderr, "To learn about workarounds for this error, run: gh help mintty") + return exitError + } + + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { + fmt.Fprintln(stderr, "Try authenticating with: gh auth login") + } else if u := factory.SSOURL(); u != "" { + // handles organization SAML enforcement error + fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) + } else if msg := httpErr.ScopesSuggestion(); msg != "" { + fmt.Fprintln(stderr, msg) + } + + return exitError + } + if root.HasFailed() { + return exitError + } + + updateCancel() // if the update checker hasn't completed by now, abort it + newRelease := <-updateMessageChan + if newRelease != nil { + isHomebrew := isUnderHomebrew(cmdFactory.Executable()) + if isHomebrew && isRecentRelease(newRelease.PublishedAt) { + // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core + return exitOK + } + fmt.Fprintf(stderr, "\n\n%s %s → %s\n", + ansi.Color("A new release of gh is available:", "yellow"), + ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"), + ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan")) + if isHomebrew { + fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh") + } + fmt.Fprintf(stderr, "%s\n\n", + ansi.Color(newRelease.URL, "yellow")) + } + + return exitOK +} + +// isExtensionCommand returns true if args resolve to an extension command. +func isExtensionCommand(rootCmd *cobra.Command, args []string) bool { + c, _, err := rootCmd.Find(args) + return err == nil && c != nil && c.GroupID == "extension" +} + +func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { + var dnsError *net.DNSError + if errors.As(err, &dnsError) { + fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name) + if debug { + fmt.Fprintln(out, dnsError) + } + fmt.Fprintln(out, "check your internet connection or https://githubstatus.com") + return + } + + fmt.Fprintln(out, err) + + var flagError *cmdutil.FlagError + if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") { + if !strings.HasSuffix(err.Error(), "\n") { + fmt.Fprintln(out) + } + fmt.Fprintln(out, cmd.UsageString()) + } +} + +func shouldCheckForUpdate() bool { + if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" { + return false + } + if os.Getenv("CODESPACES") != "" { + return false + } + return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr) +} + +func isTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} + +// based on https://github.com/watson/ci-info/blob/HEAD/index.js +func isCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { + if !shouldCheckForUpdate() { + return nil, nil + } + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + repo := updaterEnabled + stateFilePath := filepath.Join(config.StateDir(), "state.yml") + return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion) +} + +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 + } + + brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output() + if err != nil { + return false + } + + brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) + return strings.HasPrefix(ghBinary, brewBinPrefix) +} diff --git a/cmd/gh/main_test.go b/internal/ghcmd/cmd_test.go similarity index 99% rename from cmd/gh/main_test.go rename to internal/ghcmd/cmd_test.go index 01552b2bd..08bbceb85 100644 --- a/cmd/gh/main_test.go +++ b/internal/ghcmd/cmd_test.go @@ -1,4 +1,4 @@ -package main +package ghcmd import ( "bytes"