diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 5336033fd..4d801f008 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/command" "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/update" "github.com/cli/cli/utils" @@ -73,7 +74,7 @@ func main() { printError(os.Stderr, err, cmd, hasDebug) os.Exit(1) } - if command.HasFailed() { + if root.HasFailed() { os.Exit(1) } diff --git a/command/alias.go b/command/alias.go index fc27c1cb0..64b6ab19e 100644 --- a/command/alias.go +++ b/command/alias.go @@ -13,7 +13,6 @@ import ( ) func init() { - RootCmd.AddCommand(aliasCmd) aliasCmd.AddCommand(aliasSetCmd) aliasCmd.AddCommand(aliasListCmd) aliasCmd.AddCommand(aliasDeleteCmd) diff --git a/command/completion.go b/command/completion.go index 706ef311a..099b10a47 100644 --- a/command/completion.go +++ b/command/completion.go @@ -10,7 +10,6 @@ import ( ) func init() { - RootCmd.AddCommand(completionCmd) completionCmd.Flags().StringP("shell", "s", "", "Shell type: {bash|zsh|fish|powershell}") } diff --git a/command/config.go b/command/config.go index 0a77e99e5..35fa09508 100644 --- a/command/config.go +++ b/command/config.go @@ -8,7 +8,6 @@ import ( ) func init() { - RootCmd.AddCommand(configCmd) configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configSetCmd) diff --git a/command/root.go b/command/root.go index ee57d3a2d..a72b3eae4 100644 --- a/command/root.go +++ b/command/root.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "io" - "net/http" "os" "os/exec" "path/filepath" @@ -13,27 +12,17 @@ import ( "runtime/debug" "strings" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/context" - "github.com/cli/cli/git" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" - apiCmd "github.com/cli/cli/pkg/cmd/api" - gistCmd "github.com/cli/cli/pkg/cmd/gist" - issueCmd "github.com/cli/cli/pkg/cmd/issue" - prCmd "github.com/cli/cli/pkg/cmd/pr" - repoCmd "github.com/cli/cli/pkg/cmd/repo" - creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/cmd/factory" + "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/utils" "github.com/google/shlex" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) // Version is dynamically set by the toolchain or overridden by the Makefile. @@ -42,9 +31,7 @@ var Version = "DEV" // BuildDate is dynamically set at build time in the Makefile. var BuildDate = "" // YYYY-MM-DD -var versionOutput = "" - -var defaultStreams *iostreams.IOStreams +var RootCmd *cobra.Command func init() { if Version == "DEV" { @@ -52,159 +39,12 @@ func init() { Version = info.Main.Version } } - Version = strings.TrimPrefix(Version, "v") - if BuildDate == "" { - RootCmd.Version = Version - } else { - RootCmd.Version = fmt.Sprintf("%s (%s)", Version, BuildDate) - } - versionOutput = fmt.Sprintf("gh version %s\n%s\n", RootCmd.Version, changelogURL(Version)) - RootCmd.AddCommand(versionCmd) - RootCmd.SetVersionTemplate(versionOutput) - RootCmd.PersistentFlags().Bool("help", false, "Show help for command") - RootCmd.Flags().Bool("version", false, "Show gh version") - // TODO: - // RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output") - - RootCmd.SetHelpFunc(rootHelpFunc) - RootCmd.SetUsageFunc(rootUsageFunc) - - RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { - if err == pflag.ErrHelp { - return err - } - return &cmdutil.FlagError{Err: err} - }) - - defaultStreams = iostreams.System() - - // TODO: iron out how a factory incorporates context - cmdFactory := &cmdutil.Factory{ - IOStreams: defaultStreams, - HttpClient: func() (*http.Client, error) { - // TODO: decouple from `context` - ctx := context.New() - cfg, err := ctx.Config() - if err != nil { - return nil, err - } - - // TODO: avoid setting Accept header for `api` command - return httpClient(defaultStreams, cfg, true), nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - // TODO: decouple from `context` - ctx := context.New() - return ctx.BaseRepo() - }, - Remotes: func() (context.Remotes, error) { - ctx := context.New() - return ctx.Remotes() - }, - Config: func() (config.Config, error) { - cfg, err := config.ParseDefaultConfig() - if errors.Is(err, os.ErrNotExist) { - cfg = config.NewBlankConfig() - } else if err != nil { - return nil, err - } - return cfg, nil - }, - Branch: func() (string, error) { - currentBranch, err := git.CurrentBranch() - if err != nil { - return "", fmt.Errorf("could not determine current branch: %w", err) - } - return currentBranch, nil - }, - } - - RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil)) - RootCmd.AddCommand(gistCmd.NewCmdGist(cmdFactory)) - - resolvedBaseRepo := func() (ghrepo.Interface, error) { - httpClient, err := cmdFactory.HttpClient() - if err != nil { - return nil, err - } - - apiClient := api.NewClientFromHTTP(httpClient) - - ctx := context.New() - remotes, err := ctx.Remotes() - if err != nil { - return nil, err - } - repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") - if err != nil { - return nil, err - } - baseRepo, err := repoContext.BaseRepo() - if err != nil { - return nil, err - } - - return baseRepo, nil - } - - repoResolvingCmdFactory := *cmdFactory - - repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo - - RootCmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) - RootCmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) - RootCmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) - RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil)) -} - -// RootCmd is the entry point of command-line execution -var RootCmd = &cobra.Command{ - Use: "gh [flags]", - Short: "GitHub CLI", - Long: `Work seamlessly with GitHub from the command line.`, - - SilenceErrors: true, - SilenceUsage: true, - Example: heredoc.Doc(` - $ gh issue create - $ gh repo clone cli/cli - $ gh pr checkout 321 - `), - Annotations: map[string]string{ - "help:feedback": heredoc.Doc(` - Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7 - Open an issue using “gh issue create -R cli/cli” - `), - "help:environment": heredoc.Doc(` - GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being - prompted to authenticate and overrides any previously stored credentials. - - GH_REPO: specify the GitHub repository in "OWNER/REPO" format for commands that - otherwise operate on a local repository. - - GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use - for authoring text. - - BROWSER: the web browser to use for opening links. - - DEBUG: set to any value to enable verbose output to standard error. Include values "api" - or "oauth" to print detailed information about HTTP requests or authentication flow. - - GLAMOUR_STYLE: the style to use for rendering Markdown. See - https://github.com/charmbracelet/glamour#styles - - NO_COLOR: avoid printing ANSI escape sequences for color output. - `), - }, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - fmt.Print(versionOutput) - }, + cmdFactory := factory.New(Version) + RootCmd = root.NewCmdRoot(cmdFactory, Version, BuildDate) + RootCmd.AddCommand(aliasCmd) + RootCmd.AddCommand(completionCmd) + RootCmd.AddCommand(configCmd) } // overridden in tests @@ -245,62 +85,6 @@ func contextForCommand(cmd *cobra.Command) context.Context { return ctx } -// generic authenticated HTTP client for commands -func httpClient(io *iostreams.IOStreams, cfg config.Config, setAccept bool) *http.Client { - var opts []api.ClientOption - if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, apiVerboseLog()) - } - - opts = append(opts, - api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), - // antiope-preview: Checks - // FIXME: avoid setting this header for `api` command - api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"), - api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - return fmt.Sprintf("token %s", token), nil - } - - hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) - token, err := cfg.Get(hostname, "oauth_token") - if token == "" { - var notFound *config.NotFoundError - // TODO: check if stdout is TTY too - if errors.As(err, ¬Found) && io.IsStdinTTY() { - // interactive OAuth flow - token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required") - } - if err != nil { - return "", err - } - if token == "" { - // TODO: instruct user how to manually authenticate - return "", fmt.Errorf("authentication required for %s", hostname) - } - } - - return fmt.Sprintf("token %s", token), nil - }), - ) - - if setAccept { - opts = append(opts, - api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) { - // antiope-preview: Checks - accept := "application/vnd.github.antiope-preview+json" - if ghinstance.IsEnterprise(req.URL.Hostname()) { - // shadow-cat-preview: Draft pull requests - accept += ", application/vnd.github.shadow-cat-preview" - } - return accept, nil - }), - ) - } - - return api.NewHTTPClient(opts...) -} - func apiVerboseLog() api.ClientOption { logTraffic := strings.Contains(os.Getenv("DEBUG"), "api") colorize := utils.IsTerminal(os.Stderr) @@ -323,17 +107,6 @@ func colorableErr(cmd *cobra.Command) io.Writer { return err } -func changelogURL(version string) string { - path := "https://github.com/cli/cli" - r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`) - if !r.MatchString(version) { - return fmt.Sprintf("%s/releases/latest", path) - } - - url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) - return url -} - func ExecuteShellAlias(args []string) error { externalCmd := exec.Command(args[0], args[1:]...) externalCmd.Stderr = os.Stderr diff --git a/context/context.go b/context/context.go index 6cff06b4c..21ebc9b1f 100644 --- a/context/context.go +++ b/context/context.go @@ -17,8 +17,6 @@ import ( type Context interface { Branch() (string, error) SetBranch(string) - Remotes() (Remotes, error) - BaseRepo() (ghrepo.Interface, error) SetBaseRepo(string) Config() (config.Config, error) } @@ -160,7 +158,6 @@ func New() Context { // A Context implementation that queries the filesystem type fsContext struct { config config.Config - remotes Remotes branch string baseRepo ghrepo.Interface } @@ -196,60 +193,6 @@ func (c *fsContext) SetBranch(b string) { c.branch = b } -func (c *fsContext) Remotes() (Remotes, error) { - if c.remotes == nil { - gitRemotes, err := git.Remotes() - if err != nil { - return nil, err - } - if len(gitRemotes) == 0 { - return nil, errors.New("no git remotes found") - } - - sshTranslate := git.ParseSSHConfig().Translator() - resolvedRemotes := translateRemotes(gitRemotes, sshTranslate) - - // determine hostname by looking at the "main" remote - var hostname string - if mainRemote, err := resolvedRemotes.FindByName("upstream", "github", "origin", "*"); err == nil { - hostname = mainRemote.RepoHost() - } - - // filter the rest of the remotes to just that hostname - filteredRemotes := Remotes{} - for _, r := range resolvedRemotes { - if r.RepoHost() != hostname { - continue - } - filteredRemotes = append(filteredRemotes, r) - } - c.remotes = filteredRemotes - } - - if len(c.remotes) == 0 { - return nil, errors.New("no git remote found for a github.com repository") - } - return c.remotes, nil -} - -func (c *fsContext) BaseRepo() (ghrepo.Interface, error) { - if c.baseRepo != nil { - return c.baseRepo, nil - } - - remotes, err := c.Remotes() - if err != nil { - return nil, err - } - rem, err := remotes.FindByName("upstream", "github", "origin", "*") - if err != nil { - return nil, err - } - - c.baseRepo = rem - return c.baseRepo, nil -} - func (c *fsContext) SetBaseRepo(nwo string) { c.baseRepo, _ = ghrepo.FromFullName(nwo) } diff --git a/context/remote.go b/context/remote.go index 79ba0e8c8..b88878483 100644 --- a/context/remote.go +++ b/context/remote.go @@ -76,7 +76,7 @@ func (r Remote) RepoHost() string { } // TODO: accept an interface instead of git.RemoteSet -func translateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) { +func TranslateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) { for _, r := range gitRemotes { var repo ghrepo.Interface if r.FetchURL != nil { diff --git a/context/remote_test.go b/context/remote_test.go index 6d5801e26..98326b3aa 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -57,7 +57,7 @@ func Test_translateRemotes(t *testing.T) { identityURL := func(u *url.URL) *url.URL { return u } - result := translateRemotes(gitRemotes, identityURL) + result := TranslateRemotes(gitRemotes, identityURL) if len(result) != 1 { t.Errorf("got %d results", len(result)) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go new file mode 100644 index 000000000..bea610d88 --- /dev/null +++ b/pkg/cmd/factory/default.go @@ -0,0 +1,105 @@ +package factory + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" +) + +func New(appVersion string) *cmdutil.Factory { + io := iostreams.System() + + var cachedConfig config.Config + var configError error + configFunc := func() (config.Config, error) { + if cachedConfig != nil || configError != nil { + return cachedConfig, configError + } + cachedConfig, configError = config.ParseDefaultConfig() + if errors.Is(configError, os.ErrNotExist) { + cachedConfig = config.NewBlankConfig() + configError = nil + } + return cachedConfig, configError + } + + remotesFunc := remotesResolver() + + return &cmdutil.Factory{ + IOStreams: io, + Config: configFunc, + Remotes: remotesFunc, + HttpClient: func() (*http.Client, error) { + cfg, err := configFunc() + if err != nil { + return nil, err + } + + // TODO: avoid setting Accept header for `api` command + return httpClient(io, cfg, appVersion, true), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + remotes, err := remotesFunc() + if err != nil { + return nil, err + } + return remotes.FindByName("upstream", "github", "origin", "*") + }, + Branch: func() (string, error) { + currentBranch, err := git.CurrentBranch() + if err != nil { + return "", fmt.Errorf("could not determine current branch: %w", err) + } + return currentBranch, nil + }, + } +} + +// TODO: pass in a Config instance to parse remotes based on pre-authenticated hostnames +func remotesResolver() func() (context.Remotes, error) { + var cachedRemotes context.Remotes + var remotesError error + + return func() (context.Remotes, error) { + if cachedRemotes != nil || remotesError != nil { + return cachedRemotes, remotesError + } + + gitRemotes, err := git.Remotes() + if err != nil { + remotesError = err + return nil, err + } + if len(gitRemotes) == 0 { + remotesError = errors.New("no git remotes found") + return nil, remotesError + } + + sshTranslate := git.ParseSSHConfig().Translator() + resolvedRemotes := context.TranslateRemotes(gitRemotes, sshTranslate) + + // determine hostname by looking at the primary remotes + var hostname string + if mainRemote, err := resolvedRemotes.FindByName("upstream", "github", "origin", "*"); err == nil { + hostname = mainRemote.RepoHost() + } + + // filter the rest of the remotes to just that hostname + cachedRemotes = context.Remotes{} + for _, r := range resolvedRemotes { + if r.RepoHost() != hostname { + continue + } + cachedRemotes = append(cachedRemotes, r) + } + return cachedRemotes, nil + } +} diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go new file mode 100644 index 000000000..8a6dab198 --- /dev/null +++ b/pkg/cmd/factory/http.go @@ -0,0 +1,68 @@ +package factory + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/iostreams" +) + +// generic authenticated HTTP client for commands +func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { + var opts []api.ClientOption + if verbose := os.Getenv("DEBUG"); verbose != "" { + logTraffic := strings.Contains(verbose, "api") + opts = append(opts, api.VerboseLog(io.ErrOut, logTraffic, io.IsStderrTTY())) + } + + opts = append(opts, + api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)), + api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return fmt.Sprintf("token %s", token), nil + } + + hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) + token, err := cfg.Get(hostname, "oauth_token") + if token == "" { + var notFound *config.NotFoundError + // TODO: check if stdout is TTY too + if errors.As(err, ¬Found) && io.IsStdinTTY() { + // interactive OAuth flow + token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required") + } + if err != nil { + return "", err + } + if token == "" { + // TODO: instruct user how to manually authenticate + return "", fmt.Errorf("authentication required for %s", hostname) + } + } + + return fmt.Sprintf("token %s", token), nil + }), + ) + + if setAccept { + opts = append(opts, + api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) { + // antiope-preview: Checks + accept := "application/vnd.github.antiope-preview+json" + if ghinstance.IsEnterprise(req.URL.Hostname()) { + // shadow-cat-preview: Draft pull requests + accept += ", application/vnd.github.shadow-cat-preview" + } + return accept, nil + }), + ) + } + + return api.NewHTTPClient(opts...) +} diff --git a/command/help.go b/pkg/cmd/root/help.go similarity index 96% rename from command/help.go rename to pkg/cmd/root/help.go index d35c2602b..5570fbaaa 100644 --- a/command/help.go +++ b/pkg/cmd/root/help.go @@ -1,4 +1,4 @@ -package command +package root import ( "bytes" @@ -67,8 +67,12 @@ func nestedSuggestFunc(command *cobra.Command, arg string) { _ = rootUsageFunc(command) } +func isRootCmd(command *cobra.Command) bool { + return command != nil && !command.HasParent() +} + func rootHelpFunc(command *cobra.Command, args []string) { - if command.Parent() == RootCmd && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { + if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { nestedSuggestFunc(command, args[1]) hasFailed = true return @@ -141,7 +145,7 @@ Read the manual at https://cli.github.com/manual`}) helpEntries = append(helpEntries, helpEntry{"FEEDBACK", command.Annotations["help:feedback"]}) } - out := colorableOut(command) + out := command.OutOrStdout() for _, e := range helpEntries { if e.Title != "" { // If there is a title, add indentation to each line in the body diff --git a/command/help_test.go b/pkg/cmd/root/help_test.go similarity index 99% rename from command/help_test.go rename to pkg/cmd/root/help_test.go index e07542928..cbda48fe8 100644 --- a/command/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -1,4 +1,4 @@ -package command +package root import ( "testing" diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go new file mode 100644 index 000000000..15140800b --- /dev/null +++ b/pkg/cmd/root/root.go @@ -0,0 +1,147 @@ +package root + +import ( + "fmt" + "regexp" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/ghrepo" + apiCmd "github.com/cli/cli/pkg/cmd/api" + gistCmd "github.com/cli/cli/pkg/cmd/gist" + issueCmd "github.com/cli/cli/pkg/cmd/issue" + prCmd "github.com/cli/cli/pkg/cmd/pr" + repoCmd "github.com/cli/cli/pkg/cmd/repo" + creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { + cmd := &cobra.Command{ + Use: "gh [flags]", + Short: "GitHub CLI", + Long: `Work seamlessly with GitHub from the command line.`, + + SilenceErrors: true, + SilenceUsage: true, + Example: heredoc.Doc(` + $ gh issue create + $ gh repo clone cli/cli + $ gh pr checkout 321 + `), + Annotations: map[string]string{ + "help:feedback": heredoc.Doc(` + Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7 + Open an issue using “gh issue create -R cli/cli” + `), + "help:environment": heredoc.Doc(` + GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being + prompted to authenticate and overrides any previously stored credentials. + + GH_REPO: specify the GitHub repository in "OWNER/REPO" format for commands that + otherwise operate on a local repository. + + GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use + for authoring text. + + BROWSER: the web browser to use for opening links. + + DEBUG: set to any value to enable verbose output to standard error. Include values "api" + or "oauth" to print detailed information about HTTP requests or authentication flow. + + GLAMOUR_STYLE: the style to use for rendering Markdown. See + https://github.com/charmbracelet/glamour#styles + + NO_COLOR: avoid printing ANSI escape sequences for color output. + `), + }, + } + + version = strings.TrimPrefix(version, "v") + if buildDate == "" { + cmd.Version = version + } else { + cmd.Version = fmt.Sprintf("%s (%s)", version, buildDate) + } + versionOutput := fmt.Sprintf("gh version %s\n%s\n", cmd.Version, changelogURL(version)) + cmd.AddCommand(&cobra.Command{ + Use: "version", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + fmt.Print(versionOutput) + }, + }) + cmd.SetVersionTemplate(versionOutput) + cmd.Flags().Bool("version", false, "Show gh version") + + cmd.SetOut(f.IOStreams.Out) + cmd.SetErr(f.IOStreams.ErrOut) + + cmd.PersistentFlags().Bool("help", false, "Show help for command") + cmd.SetHelpFunc(rootHelpFunc) + cmd.SetUsageFunc(rootUsageFunc) + + cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } + return &cmdutil.FlagError{Err: err} + }) + + // CHILD COMMANDS + + cmd.AddCommand(apiCmd.NewCmdApi(f, nil)) + cmd.AddCommand(gistCmd.NewCmdGist(f)) + cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) + + // below here at the commands that require the "intelligent" BaseRepo resolver + repoResolvingCmdFactory := *f + repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo(f) + + cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) + cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) + cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) + + return cmd +} + +func resolvedBaseRepo(f *cmdutil.Factory) func() (ghrepo.Interface, error) { + return func() (ghrepo.Interface, error) { + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + remotes, err := f.Remotes() + if err != nil { + return nil, err + } + repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + if err != nil { + return nil, err + } + baseRepo, err := repoContext.BaseRepo() + if err != nil { + return nil, err + } + + return baseRepo, nil + } +} + +func changelogURL(version string) string { + path := "https://github.com/cli/cli" + r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`) + if !r.MatchString(version) { + return fmt.Sprintf("%s/releases/latest", path) + } + + url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) + return url +} diff --git a/command/root_test.go b/pkg/cmd/root/root_test.go similarity index 98% rename from command/root_test.go rename to pkg/cmd/root/root_test.go index c0b1fd7d3..714032ec3 100644 --- a/command/root_test.go +++ b/pkg/cmd/root/root_test.go @@ -1,4 +1,4 @@ -package command +package root import ( "testing"