mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(help): group root help, show relevant flags and add help topics
The root help lists commands in gh-style groups (core, Actions, additional and alias commands) followed by help topics, examples and pointers. Every help page uses USAGE, ALIASES, FLAGS, INHERITED FLAGS, ARGUMENTS, JSON FIELDS, EXAMPLES and LEARN MORE sections. Inherited flags show --repo, --remote and --cwd only where a repository is selected, --json and --jq where JSON is printed, --yes where the command confirms, and one line for the remaining global flags. tea help environment, exit-codes and formatting document global flags, environment variables, exit codes and JSON output. tea help with an unknown topic is a usage error with suggestions instead of printing the root help. A test keeps Shorts, examples and Long texts complete, with allowlists for the commands that still lack them. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
3 files changed
+781
-9
No files matched your search
+31
-9
@@ -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"))
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -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 <command> <subcommand> [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 <command> <subcommand> --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 <command> [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 <number>", 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
|
||||
`
|
||||
)
|
||||
@@ -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 <command> <subcommand> --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 <command> <subcommand> [flags]"
|
||||
case cmd.Annotations["tea.group"] != "":
|
||||
return cmd.CommandPath() + " <command> [flags]"
|
||||
case cmd.HasAvailableSubCommands():
|
||||
return cmd.UseLine() + "\n" + cmd.CommandPath() + " <command> [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
|
||||
}
|
||||
Reference in new issue
Block a user