diff --git a/internal/app/app.go b/internal/app/app.go index c41ffd60..a371b231 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ package app import ( + "cmp" "context" "errors" "fmt" @@ -98,18 +99,18 @@ func NewRoot(deps Dependencies) *cobra.Command { return client(instance, token, insecure) } } - root := &cobra.Command{Use: "tea", Short: "Work with Gitea from your terminal", Version: version.Format(), SilenceErrors: true, SilenceUsage: true} + root := &cobra.Command{Use: "tea", Short: "Work with Gitea from your terminal", Long: "Work seamlessly with Gitea from the command line.", Version: version.Format(), SilenceErrors: true, SilenceUsage: true} root.SetIn(deps.Streams.In) root.SetOut(deps.Streams.Out) root.SetErr(deps.Streams.ErrOut) root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, err) }) flags := root.PersistentFlags() - flags.StringVar(&a.options.host, "host", "", "Gitea instance URL, including any base path") - flags.StringVar(&a.options.user, "user", "", "Account on the selected instance") - flags.StringVar(&a.options.sudo, "sudo", "", "Perform API requests as this user (requires an administrator account)") - flags.StringVarP(&a.options.repo, "repo", "R", "", "Repository owner/name or URL") - flags.StringVar(&a.options.cwd, "cwd", "", "Working directory for repository discovery") - flags.StringVar(&a.options.remote, "remote", "", "Git remote used for repository discovery") + flags.StringVar(&a.options.host, "host", "", "Gitea instance `URL`, including any base path") + flags.StringVar(&a.options.user, "user", "", "Account `name` on the selected instance") + flags.StringVar(&a.options.sudo, "sudo", "", "Perform API requests as this `user` (requires an administrator account)") + flags.StringVarP(&a.options.repo, "repo", "R", "", "Select a repository as `OWNER/REPO` or URL") + flags.StringVar(&a.options.cwd, "cwd", "", "Working `directory` for repository discovery") + flags.StringVar(&a.options.remote, "remote", "", "Git `remote` used for repository discovery") flags.StringVar(&a.options.json, "json", "", "Output JSON with the specified comma-separated `fields`, or all fields") flags.Lookup("json").NoOptDefVal = "*" flags.StringVarP(&a.options.jq, "jq", "q", "", "Filter JSON output using a jq `expression`; implies --json") @@ -142,18 +143,39 @@ func NewRoot(deps Dependencies) *cobra.Command { } root.AddCommand(a.configCommand(), a.aliasCommand(), a.authCommand(), a.apiCommand(), a.repoCommand(), a.issueCommand(), a.prCommand(), a.doctorCommand(), a.workflowCommand(), a.runCommand(), a.labelCommand(), a.milestoneCommand(), a.sshKeyCommand(), a.timeCommand(), a.wikiCommand(), a.actionValueCommand(true), a.actionValueCommand(false), a.releaseCommand(), a.organizationCommand(), a.notificationCommand(), a.teamCommand(), a.branchCommand(), a.webhookCommand(), a.adminCommand(), a.searchCommand(), a.statusCommand(false)) root.AddCommand(a.manCommand()) - root.AddCommand(&cobra.Command{Use: "version", Short: "Print tea's version", Annotations: textOutput("version prints text"), RunE: func(cmd *cobra.Command, _ []string) error { + root.AddCommand(&cobra.Command{Use: "version", Short: "Print tea's version", Example: "$ tea version", Annotations: textOutput("version prints text"), RunE: func(cmd *cobra.Command, _ []string) error { _, err := fmt.Fprintln(cmd.OutOrStdout(), version.Format()) return err }}) + root.AddCommand(helpTopics(flags)...) + root.SetHelpCommand(a.helpCommand()) root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() root.SetVersionTemplate("{{.Version}}\n") + root.SetHelpFunc(a.writeHelp) + root.AddGroup( + &cobra.Group{ID: "core", Title: "Core commands"}, + &cobra.Group{ID: "actions", Title: "Actions commands"}, + &cobra.Group{ID: "additional", Title: "Additional commands"}, + &cobra.Group{ID: "alias", Title: "Alias commands"}, + ) + for _, cmd := range root.Commands() { + if cmd.Annotations[topicAnnotation] == "" { + cmd.GroupID = cmp.Or(rootGroups[cmd.Name()], "additional") + } + } rejectUnknownSubcommands(root) addVerbAliases(root) return root } +// rootGroups places the core and Actions commands in their root help groups (D26); every other +// command is additional. +var rootGroups = map[string]string{ + "auth": "core", "issue": "core", "org": "core", "pr": "core", "release": "core", "repo": "core", + "run": "actions", "secret": "actions", "variable": "actions", "workflow": "actions", +} + // verbAliases are the gh-style short names that every command with a common verb accepts (D6). // Commands add other gh verbs, such as new, co, add and get, to their own Aliases. var verbAliases = map[string]string{"list": "ls", "delete": "rm"} @@ -285,7 +307,7 @@ func Execute(ctx context.Context, deps Dependencies, args []string) error { if loadErr == nil { for name, expansion := range snapshot.Preferences.Aliases { if aliases.Check(name, snapshot.Preferences.Aliases, names) == nil { - root.AddCommand(&cobra.Command{Use: name, Short: "Alias for " + expansion, Annotations: map[string]string{"tea.alias": "true"}, RunE: func(*cobra.Command, []string) error { + root.AddCommand(&cobra.Command{Use: name, Short: fmt.Sprintf("Alias for %q", expansion), GroupID: "alias", Annotations: map[string]string{"tea.alias": "true"}, RunE: func(*cobra.Command, []string) error { return clierr.Wrap(clierr.Usage, errors.New("alias must be expanded before execution")) }}) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1e8054c3..afd4f839 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -10,7 +10,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" + "strings" "testing" + "unicode" "gitea.dev/tea/internal/clierr" "github.com/spf13/cobra" @@ -193,6 +196,114 @@ func TestRawJSONCommandsKeepJQToThemselves(t *testing.T) { require.ErrorContains(t, root.Execute(), "raw prints text; --json is not supported") } +func TestRootHelpGroupsCommandsAndTopics(t *testing.T) { + deps, out, _ := testDeps(t) + require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "co", "pr checkout"})) + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"--help"})) + help := out.String() + for _, want := range []string{ + "Work seamlessly with Gitea from the command line.\n\nUSAGE\n tea [flags]\n", + "\nCORE COMMANDS\n auth: ", + "\n repo: ", + "\nACTIONS COMMANDS\n run: ", + "\nADDITIONAL COMMANDS\n admin: ", + "\nALIAS COMMANDS\n co: Alias for \"pr checkout\"\n", + "\nHELP TOPICS\n environment: ", + "\nFLAGS\n --help Show help for command\n --version Show tea version\n", + "\nEXAMPLES\n $ tea issue create\n", + "\nLEARN MORE\n Use `tea --help` for more information about a command.\n", + } { + require.Contains(t, help, want) + } + require.NotContains(t, help, " help:") + require.NotContains(t, help, " man:") + // Every visible top-level command belongs to one root help group. + for _, cmd := range NewRoot(deps).Commands() { + if cmd.IsAvailableCommand() { + require.Contains(t, []string{"core", "actions", "additional"}, cmd.GroupID, cmd.Name()) + } + } +} + +func TestCommandHelpShowsRelevantFlagsOnly(t *testing.T) { + deps, out, _ := testDeps(t) + require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "--help"})) + help := out.String() + for _, want := range []string{ + "\nUSAGE\n tea issue list [flags]\n", + "\nALIASES\n tea issue ls\n", + "\nFLAGS\n --assignee string ", + "\nINHERITED FLAGS\n -R, --repo OWNER/REPO ", + "\n --json fields ", + "\n -q, --jq expression ", + "\n -h, --help Show help for command\n\n " + globalFlagsLine + "\n", + "\nJSON FIELDS\n assignees, author, body, ", + } { + require.Contains(t, help, want) + } + for _, unwanted := range []string{"--sudo string", "--yes", `[="*"]`, "Global Flags:"} { + require.NotContains(t, help, unwanted) + } + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "--help"})) + require.Contains(t, out.String(), "\nFLAGS\n --limit int Maximum results (1..100) (default 30)\n --org string ") + for _, args := range [][]string{{"version", "--help"}, {"alias", "list", "--help"}, {"auth", "status", "--help"}} { + out.Reset() + require.NoError(t, Execute(context.Background(), deps, args)) + require.NotContains(t, out.String(), "--repo", args) + require.Contains(t, out.String(), globalFlagsLine, args) + } + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"version", "--help"})) + require.NotContains(t, out.String(), "--json fields") + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"issue", "--help"})) + require.Contains(t, out.String(), "\nUSAGE\n tea issue [flags]\n\nAVAILABLE COMMANDS\n attachment: ") + require.NotContains(t, out.String(), "JSON FIELDS") +} + +func TestHelpConfirmAndArgumentsAnnotations(t *testing.T) { + deps, out, _ := testDeps(t) + root := NewRoot(deps) + issue, _, err := root.Find([]string{"issue"}) + require.NoError(t, err) + issue.AddCommand(&cobra.Command{Use: "drop ", Short: "Drop an issue", Example: "# Drop issue 7\n$ tea issue drop 7", Annotations: map[string]string{"tea.confirm": "true", "tea.arguments": "An issue is a number or a URL."}, RunE: func(*cobra.Command, []string) error { return nil }}) + root.SetArgs([]string{"issue", "drop", "--help"}) + require.NoError(t, root.Execute()) + require.Contains(t, out.String(), "\n -y, --yes ") + require.Contains(t, out.String(), "\nARGUMENTS\n An issue is a number or a URL.\n\nJSON FIELDS\n") + require.Contains(t, out.String(), "\nEXAMPLES\n # Drop issue 7\n $ tea issue drop 7\n") +} + +func TestHelpTopicsAndUnknownTopics(t *testing.T) { + deps, out, _ := testDeps(t) + for args, want := range map[string]string{ + "help environment": " TEA_CONFIG_DIR ", + "help exit-codes": " 4 authentication", + "help formatting": "--jq runs inside tea", + "help issue list": "\nUSAGE\n tea issue list [flags]\n", + "environment": " -R, --repo OWNER/REPO ", + } { + out.Reset() + require.NoError(t, Execute(context.Background(), deps, strings.Fields(args)), args) + require.Contains(t, out.String(), want, args) + } + for args, want := range map[string]string{ + "help nope": `unknown help topic "nope"; run tea help to list commands and topics`, + "help isue": `unknown help topic "isue"; did you mean issue?`, + "help isue list": `unknown help topic "isue list"; did you mean issue?`, + "help env": `unknown help topic "env"; did you mean environment?`, + "help issue nope": `unknown help topic "issue nope"; run tea help to list commands and topics`, + } { + out.Reset() + err := Execute(context.Background(), deps, strings.Fields(args)) + require.Equal(t, clierr.Usage, clierr.ExitCode(err), args) + require.EqualError(t, err, want, args) + require.Empty(t, out.String(), args) + } +} + func TestVerbAndFlagAliases(t *testing.T) { deps, _, _ := testDeps(t) root := NewRoot(deps) @@ -221,6 +332,56 @@ func TestVerbAndFlagAliases(t *testing.T) { require.NotContains(t, edit.UsageString(), "add-labels") } +// TestHelpTextsAreComplete checks the help texts of every command: a Short line without a final period, +// a capitalized hyphenated verb ("Mark-read …") or a sibling's wording, an Example on every command and a +// Long text on every command group. Area streams remove the commands they complete from the allowlists +// below; S8 deletes the allowlists. +func TestHelpTextsAreComplete(t *testing.T) { + deps, _, _ := testDeps(t) + pending := func(list string) map[string]bool { + set := map[string]bool{} + for path := range strings.Lines(list) { + if path = strings.TrimSpace(path); path != "" { + set[path] = true + } + } + return set + } + shorts, examples, longs := pending(shortsToFix), pending(examplesToWrite), pending(longsToWrite) + walkCommands(NewRoot(deps), func(cmd *cobra.Command) { + if !cmd.HasParent() || !cmd.IsAvailableCommand() { + return + } + path := strings.TrimPrefix(cmd.CommandPath(), "tea ") + generated := strings.Contains(cmd.Name(), "-") && strings.HasPrefix(cmd.Short, titleCase(cmd.Name())+" ") + siblings := slices.ContainsFunc(cmd.Parent().Commands(), func(other *cobra.Command) bool { return other != cmd && other.Short == cmd.Short }) + badShort := cmd.Short == "" || strings.HasSuffix(cmd.Short, ".") || unicode.IsLower(rune(cmd.Short[0])) || generated || siblings + checks := []struct { + name string + missing bool + pending map[string]bool + }{ + {"a clear Short", badShort, shorts}, + {"an Example", cmd.Example == "" && cmd.Annotations["tea.group"] == "", examples}, + {"a Long text", cmd.Long == "" && cmd.Annotations["tea.group"] != "", longs}, + } + for _, check := range checks { + switch { + case check.missing && !check.pending[path]: + t.Errorf("%s needs %s", path, check.name) + case !check.missing && check.pending[path]: + t.Errorf("%s has %s now; remove it from the allowlist", path, check.name) + } + delete(check.pending, path) + } + }) + for _, check := range []map[string]bool{shorts, examples, longs} { + for path := range check { + t.Errorf("%s is allowlisted but is not a command", path) + } + } +} + // walkCommands calls visit for cmd and each of its descendants. func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) { visit(cmd) @@ -228,3 +389,245 @@ func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) { walkCommands(child, visit) } } + +// shortsToFix, examplesToWrite and longsToWrite list the commands whose help texts area streams +// have yet to complete; see TestHelpTextsAreComplete. +const ( + shortsToFix = ` + config get + config list + notification mark-read + notification mark-unread + pr review cancel-request +` + examplesToWrite = ` + admin user create + admin user delete + admin user edit + admin user list + admin user view + alias delete + alias list + alias set + api + auth login + auth logout + auth refresh + auth setup-git + auth status + auth switch + branch create + branch delete + branch list + branch protect + branch rename + branch unprotect + branch view + completion bash + completion fish + completion powershell + completion zsh + config get + config list + config path + config set + doctor + issue attachment delete + issue attachment download + issue attachment edit + issue attachment list + issue attachment upload + issue attachment view + issue close + issue comment create + issue comment delete + issue comment edit + issue comment list + issue comment view + issue create + issue edit + issue list + issue reopen + issue view + label create + label delete + label edit + label export + label list + milestone close + milestone create + milestone delete + milestone edit + milestone issue add + milestone issue list + milestone issue remove + milestone list + milestone reopen + milestone view + notification list + notification mark-read + notification mark-unread + notification pin + notification unpin + notification view + notification watch + org create + org delete + org edit + org list + org member list + org member private + org member public + org member remove + org view + pr attachment delete + pr attachment download + pr attachment edit + pr attachment list + pr attachment upload + pr attachment view + pr checkout + pr checks + pr clean + pr close + pr comment create + pr comment delete + pr comment edit + pr comment list + pr comment view + pr create + pr diff + pr edit + pr list + pr merge + pr reopen + pr review + pr review cancel-request + pr review comments + pr review delete + pr review dismiss + pr review list + pr review reply + pr review request + pr review resolve + pr review submit + pr review undismiss + pr review unresolve + pr review view + pr status + pr view + release asset delete + release asset download + release asset edit + release asset list + release asset upload + release create + release delete + release edit + release list + release publish + release view + repo archive + repo clone + repo create + repo delete + repo edit + repo fork + repo list + repo migrate + repo set-default + repo sync + repo view + run artifact download + run artifact list + run cancel + run delete + run jobs + run list + run logs + run rerun + run view + run watch + search issue + search pr + search repo + secret delete + secret list + secret set + ssh-key create + ssh-key delete + ssh-key list + ssh-key view + status + team create + team delete + team edit + team list + team member add + team member list + team member remove + team repo add + team repo list + team repo remove + team view + time create + time delete + time list + time reset + variable delete + variable list + variable set + variable view + webhook create + webhook delete + webhook edit + webhook list + webhook view + wiki create + wiki delete + wiki edit + wiki list + wiki revisions + wiki view + workflow disable + workflow enable + workflow list + workflow run + workflow view +` + longsToWrite = ` + admin + admin user + alias + auth + branch + issue + issue attachment + issue comment + label + milestone + milestone issue + notification + org + org member + pr + pr attachment + pr comment + release + release asset + repo + run + run artifact + search + secret + ssh-key + team + team member + team repo + time + variable + webhook + wiki + workflow +` +) diff --git a/internal/app/help_topics.go b/internal/app/help_topics.go new file mode 100644 index 00000000..acbb4bfd --- /dev/null +++ b/internal/app/help_topics.go @@ -0,0 +1,347 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "cmp" + "fmt" + "io" + "slices" + "strings" + + "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/terminal" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Help pages use gh-style sections: USAGE, the command lists, FLAGS, INHERITED FLAGS, ARGUMENTS, +// JSON FIELDS, EXAMPLES and LEARN MORE. Commands document themselves through these fields: +// +// - Short: one line, a capitalized verb phrase without a final period ("List issues"). +// - Long: optional paragraphs shown instead of Short on the command's own page. +// - Example: unindented lines; "$ tea …" for commands and "# …" for comments above them. +// - Annotations["tea.arguments"]: the ARGUMENTS section, describing accepted argument formats. +// - Annotations["tea.confirm"]: "true" when the command asks for confirmation, which shows --yes. + +const ( + // topicAnnotation marks a hidden command that only holds a help topic. + topicAnnotation = "tea.topic" + // globalFlagsLine names the global flags that every help page leaves out of its flag lists. + globalFlagsLine = "Global flags: --host, --user, --sudo, --no-input, --interactive, --debug (see tea help environment)" +) + +// noRepoCommands are command paths, with their subcommands, that never select a repository, so their +// help omits --repo, --remote and --cwd. +var noRepoCommands = []string{ + "tea admin", "tea alias", "tea auth", "tea completion", "tea config", "tea help", "tea man", "tea org", + "tea repo create", "tea repo list", "tea search repo", "tea ssh-key", "tea team", "tea version", +} + +// writeHelp prints the help page of cmd. +func (a *application) writeHelp(cmd *cobra.Command, _ []string) { + if cmd.Annotations[topicAnnotation] != "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(cmd.Long)) + return + } + color := a.deps.Streams.OutTTY && a.colorEnabled() + var page strings.Builder + section := func(title, body string) { + if body = strings.Trim(body, "\n"); body != "" { + fmt.Fprintf(&page, "%s\n%s\n\n", terminal.Emphasis(title, color), indent(body)) + } + } + if description := cmp.Or(cmd.Long, cmd.Short); description != "" { + page.WriteString(description + "\n\n") + } + section("USAGE", usageLines(cmd)) + section("ALIASES", aliasLines(cmd)) + commands := availableCommands(cmd) + width := 0 + for _, children := range commands { + for _, child := range children.commands { + width = max(width, len(child.Name())+3) + } + } + for _, children := range commands { + var lines strings.Builder + for _, child := range children.commands { + fmt.Fprintf(&lines, "%-*s%s\n", width, child.Name()+":", child.Short) + } + section(children.title, lines.String()) + } + if !cmd.HasParent() { + section("FLAGS", "--help Show help for command\n--version Show tea version") + } else { + local, inherited := helpFlags(cmd) + section("FLAGS", local) + section("INHERITED FLAGS", inherited+"\n\n"+globalFlagsLine) + } + section("ARGUMENTS", cmd.Annotations["tea.arguments"]) + if outputsJSON(cmd) { + section("JSON FIELDS", wrap(strings.Join(commandOutputFields(cmd), ", "), 78)) + } + if !cmd.HasParent() { + section("EXAMPLES", "$ tea issue create\n$ tea issue list --label bug\n$ tea pr checkout 13\n$ tea repo clone owner/name") + } else { + section("EXAMPLES", cmd.Example) + } + section("LEARN MORE", "Use `tea --help` for more information about a command.\n"+ + "Read `tea help environment` for global flags and environment variables.\n"+ + "Read `tea help formatting` for --json and --jq, and `tea help exit-codes` for exit codes.") + _, _ = io.WriteString(cmd.OutOrStdout(), strings.TrimRight(page.String(), "\n")+"\n") +} + +// indent indents every non-empty line of text by two spaces. +func indent(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + if line != "" { + lines[i] = " " + line + } + } + return strings.Join(lines, "\n") +} + +// usageLines shows how to run cmd; command groups take a subcommand. +func usageLines(cmd *cobra.Command) string { + switch { + case !cmd.HasParent(): + return "tea [flags]" + case cmd.Annotations["tea.group"] != "": + return cmd.CommandPath() + " [flags]" + case cmd.HasAvailableSubCommands(): + return cmd.UseLine() + "\n" + cmd.CommandPath() + " [flags]" + } + return cmd.UseLine() +} + +// aliasLines shows the other names of cmd. +func aliasLines(cmd *cobra.Command) string { + lines := make([]string, 0, len(cmd.Aliases)) + for _, alias := range cmd.Aliases { + lines = append(lines, cmd.Parent().CommandPath()+" "+alias) + } + return strings.Join(lines, "\n") +} + +// commandList is one titled list of commands on a help page. +type commandList struct { + title string + commands []*cobra.Command +} + +// availableCommands lists the subcommands of cmd by group, the ungrouped ones, then help topics. +func availableCommands(cmd *cobra.Command) []commandList { + var lists []commandList + for _, group := range cmd.Groups() { + lists = append(lists, commandList{title: strings.ToUpper(group.Title)}) + } + other, topics := commandList{title: "AVAILABLE COMMANDS"}, commandList{title: "HELP TOPICS"} + for _, child := range cmd.Commands() { + switch at := slices.IndexFunc(cmd.Groups(), func(g *cobra.Group) bool { return g.ID == child.GroupID }); { + case child.Annotations[topicAnnotation] != "": + topics.commands = append(topics.commands, child) + case !child.IsAvailableCommand(): + case at >= 0: + lists[at].commands = append(lists[at].commands, child) + default: + other.commands = append(other.commands, child) + } + } + return append(lists, other, topics) +} + +// helpFlags renders the flags of cmd and the global flags that apply to it: --repo, --remote and --cwd +// unless it never selects a repository, --json and --jq when it outputs JSON, and --yes when it asks +// for confirmation. Persistent flags of parent commands, such as --org, count as its own. +func helpFlags(cmd *cobra.Command) (local, inherited string) { + globals := cmd.Root().PersistentFlags() + var own []*pflag.Flag + for _, flags := range []*pflag.FlagSet{cmd.LocalFlags(), cmd.InheritedFlags()} { + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" && globals.Lookup(flag.Name) != flag { + own = append(own, flag) + } + }) + } + slices.SortFunc(own, func(x, y *pflag.Flag) int { return strings.Compare(x.Name, y.Name) }) + path := cmd.CommandPath() + repo := !slices.ContainsFunc(noRepoCommands, func(prefix string) bool { return path == prefix || strings.HasPrefix(path, prefix+" ") }) + shown := map[string]bool{"repo": repo, "remote": repo, "cwd": repo, "json": outputsJSON(cmd), "jq": outputsJSON(cmd), "yes": cmd.Annotations["tea.confirm"] != ""} + var shared []*pflag.Flag + for _, name := range []string{"repo", "remote", "cwd", "json", "jq", "yes"} { + if shown[name] { + shared = append(shared, globals.Lookup(name)) + } + } + if help := cmd.Flags().Lookup("help"); help != nil { + shared = append(shared, help) + } + return flagUsages(own), flagUsages(shared) +} + +// outputsJSON reports whether the --json flag applies to cmd. +func outputsJSON(cmd *cobra.Command) bool { + return cmd.Annotations["tea.text"] == "" && cmd.Annotations["tea.group"] == "" && commandOutputFields(cmd) != nil +} + +// flagUsages renders flags in order as aligned usage lines without their common indentation. The +// optional --json value and the help flag read as in gh: "--json fields", "Show help for command". +func flagUsages(flags []*pflag.Flag) string { + set := pflag.NewFlagSet("help", pflag.ContinueOnError) + set.SortFlags = false + for _, flag := range flags { + shown := *flag + switch flag.Name { + case "json": + shown.NoOptDefVal = "" + case "help": + shown.Usage = "Show help for command" + } + set.AddFlag(&shown) + } + lines := strings.Split(strings.TrimRight(set.FlagUsages(), "\n"), "\n") + common := -1 + for _, line := range lines { + if strings.TrimSpace(line) != "" { + spaces := len(line) - len(strings.TrimLeft(line, " ")) + if common < 0 || spaces < common { + common = spaces + } + } + } + for i, line := range lines { + lines[i] = line[min(max(common, 0), len(line)):] + } + return strings.Join(lines, "\n") +} + +// wrap breaks text at spaces into lines of at most width characters. +func wrap(text string, width int) string { + var lines []string + line := "" + for _, word := range strings.Fields(text) { + if line != "" && len(line)+1+len(word) > width { + lines, line = append(lines, line), "" + } + line = strings.TrimPrefix(line+" "+word, " ") + } + return strings.Join(append(lines, line), "\n") +} + +// helpCommand prints the help of a command or topic, and rejects unknown ones. +func (a *application) helpCommand() *cobra.Command { + return &cobra.Command{ + Use: "help [command...]", Short: "Show help for a command or topic", + Annotations: textOutput("help prints text"), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + target, _, err := cmd.Root().Find(args) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var names []cobra.Completion + for _, child := range target.Commands() { + if (child.IsAvailableCommand() || child.Annotations[topicAnnotation] != "") && strings.HasPrefix(child.Name(), toComplete) { + names = append(names, cobra.CompletionWithDesc(child.Name(), child.Short)) + } + } + return names, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + target, rest, err := cmd.Root().Find(args) + if err == nil && (len(rest) == 0 || !target.HasSubCommands()) { + target.InitDefaultHelpFlag() + return target.Help() + } + if len(rest) == 0 { + rest = args[len(args)-1:] + } + return unknownHelpTopic(target, args, rest[0]) + }, + } +} + +// unknownHelpTopic reports a help request for a command or topic that does not exist; name is the +// first word that parent does not know. +func unknownHelpTopic(parent *cobra.Command, args []string, name string) error { + err := fmt.Errorf("unknown help topic %q", strings.Join(args, " ")) + suggestions := parent.SuggestionsFor(name) + for _, child := range parent.Commands() { + if child.Annotations[topicAnnotation] != "" && strings.HasPrefix(child.Name(), name) { + suggestions = append(suggestions, child.Name()) + } + } + if len(suggestions) > 0 { + err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or ")) + } else { + err = fmt.Errorf("%w; run tea help to list commands and topics", err) + } + return clierr.Wrap(clierr.Usage, err) +} + +// helpTopics returns the help topic commands; globals are the flags that every command accepts. +func helpTopics(globals *pflag.FlagSet) []*cobra.Command { + var flags []*pflag.Flag + globals.VisitAll(func(flag *pflag.Flag) { flags = append(flags, flag) }) + topics := []*cobra.Command{ + {Use: "environment", Short: "Environment variables and global flags", Long: `Global flags work with every command, before or after the command name: + +` + indent(flagUsages(flags)) + ` + +tea selects the repository from --repo, TEA_REPO, git config tea.repository or a +Git remote (--remote, else the current branch's remote, upstream or origin), and the +instance from the repository URL, --host, TEA_HOST or GITEA_INSTANCE_URL, default_host, +or the only configured instance. + +Environment variables: + + TEA_CONFIG_DIR configuration directory; tea config path prints the files + TEA_HOST, GITEA_INSTANCE_URL instance URL, overriding default_host + GITEA_TOKEN access token for that instance instead of stored credentials + GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true + GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes + TEA_USER account on the selected instance, like --user + TEA_REPO repository as OWNER/REPO or URL, like --repo + TEA_EDITOR editor, overriding config.yml + TEA_PAGER pager, overriding config.yml + TEA_COLOR color preference: auto, always or never + NO_COLOR disables automatic color when set + VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none + +Preferences in config.yml are described in tea config --help.`}, + {Use: "exit-codes", Short: "Exit codes used by tea", Long: `tea exits with one of these codes: + + 0 success, including an empty list + 1 the command failed + 2 usage error: an unknown command or flag, or a missing or invalid argument + 3 canceled: a declined confirmation, an aborted prompt or a timeout + 4 authentication is missing or was rejected + 8 tea pr checks: some checks are still pending + 130 interrupted with Ctrl-C + +A command may document further codes in its help.`}, + {Use: "formatting", Short: "JSON output with --json and --jq", Long: `tea prints text for people by default. Scripts should use JSON: the columns and +wording of text output may change between releases. + + --json print every field + --json FIELDS print only the comma-separated FIELDS, such as --json number,title + -q, --jq EXPR filter the JSON with a jq expression; implies --json + +The JSON FIELDS section of a command's help lists its fields, and shell completion +offers them after --json. An unknown field reports the available ones. + +--jq runs inside tea, so jq does not need to be installed. String results print +without quotes: + + $ tea issue list --json number,title --jq '.[] | "\(.number)\t\(.title)"' + +On a terminal, JSON is indented and colored; otherwise each value prints on one line. +Commands that return no resource, such as deletes, reject --json.`}, + } + for _, topic := range topics { + topic.Hidden, topic.Annotations = true, map[string]string{topicAnnotation: "true"} + } + return topics +}