internal/control/help.go
227 lines · 7210 bytes
1package control
2
3import (
4 "fmt"
5 "io"
6 "slices"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/termtext"
11)
12
13func init() {
14 register(Command{
15 Path: []string{"help"},
16 Summary: "list available commands",
17 Usage: "help [<prefix>...]",
18 Examples: []string{"help", "help repo"},
19 ReadOnly: true,
20 Run: runHelp,
21 })
22}
23
24// nounSummaries is one line per distinct first path element in the
25// registry, reusing the CLI's group short texts (cmd/gitbay/main.go)
26// where the noun matches, so the two agree.
27var nounSummaries = map[string]string{
28 "account": "export or import your account, for instance migration",
29 "admin": "instance administration (admins)",
30 "audit": "instance audit log (admins)",
31 "build": "CI builds",
32 "dashboard": "pinned repos, open MRs, assigned issues, recent builds",
33 "email": "manage email addresses",
34 "explore": "public repositories on this instance",
35 "feed": "activity on repositories you can reach",
36 "help": "list available commands",
37 "issue": "issues",
38 "keys": "manage SSH keys",
39 "label": "issue labels",
40 "milestone": "group issues and MRs toward a release",
41 "mr": "merge requests",
42 "notifications": "your notification inbox",
43 "org": "organizations",
44 "pgp": "manage OpenPGP keys",
45 "profile": "user and org profiles",
46 "register": "create an account on this instance",
47 "release": "tag-anchored releases with notes and assets",
48 "repo": "create and manage repositories",
49 "runner": "the claim/report loop CI runners use",
50 "search": "find repositories, issues and merge requests",
51 "snippet": "shared text files, outside any repository",
52 "status": "commit statuses (CI)",
53 "token": "API tokens (minted over SSH, used with the JSON API)",
54 "web": "browser session",
55 "webhook": "outbound event delivery",
56 "whoami": "show the authenticated account",
57 "wiki": "a repository's wiki pages",
58}
59
60// NounSummaries returns nounSummaries, for the CLI group-text agreement
61// test (task 4.5).
62func NounSummaries() map[string]string { return nounSummaries }
63
64// helpEntry is one row of the registry as help reports it.
65type helpEntry struct {
66 Path string `json:"path"`
67 Summary string `json:"summary"`
68 Usage string `json:"usage"`
69 Flags []Flag `json:"flags,omitempty"`
70 Examples []string `json:"examples,omitempty"`
71}
72
73// runHelp lists the registry, sorted, so a noun's commands sit together.
74// Bare `help` stays one line per command. A prefix that names one command
75// exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a
76// noun with several commands under it renders a READ/WRITE summary.
77func runHelp(c *Ctx, args []string) int {
78 prefix := joinPath(args)
79 var matched []Command
80 for _, cmd := range registry {
81 p := joinPath(cmd.Path)
82 if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") {
83 matched = append(matched, cmd)
84 }
85 }
86 if len(matched) == 0 {
87 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
88 }
89 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
90 entries := make([]helpEntry, len(matched))
91 for i, cmd := range matched {
92 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
93 }
94 return c.emit(entries, func(w io.Writer) {
95 switch {
96 case prefix == "":
97 for _, e := range entries {
98 summary := e.Summary
99 if c.Term.Cols > 0 {
100 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
101 summary = clip(summary, avail)
102 }
103 }
104 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
105 }
106 case joinPath(matched[0].Path) == prefix:
107 c.helpVerb(w, matched[0], matched[1:])
108 default:
109 c.helpNoun(w, prefix, matched)
110 }
111 })
112}
113
114// program is how help spells the command it documents: the CLI at a
115// terminal (only the CLI sends GITBAY_TERM), ssh otherwise.
116func (c *Ctx) program() string {
117 if c.Term.Cols > 0 {
118 return "gitbay"
119 }
120 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
121}
122
123func (c *Ctx) heading(w io.Writer, s string) {
124 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
125}
126
127// wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging
128// indent under prefix when it would overflow. Plain output never wraps.
129func (c *Ctx) wrapLine(w io.Writer, prefix, text string) {
130 if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols {
131 fmt.Fprintln(w, prefix+text)
132 return
133 }
134 indent := cells(prefix)
135 width := c.Term.Cols - indent
136 for i, line := range termtext.Wrap(text, width) {
137 if i == 0 {
138 fmt.Fprintln(w, prefix+line)
139 } else {
140 fmt.Fprintln(w, strings.Repeat(" ", indent)+line)
141 }
142 }
143}
144
145func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
146 fmt.Fprintln(w, cmd.Summary)
147 fmt.Fprintln(w)
148 c.heading(w, "USAGE")
149 // Cutting at the first optional flag drops the rest of the usage
150 // syntax behind "[flags]" — safe only for what is actually optional.
151 // A required flag (repo delete --yes) or an alternative
152 // (notifications read <id>... | --all) has no " [--" to cut at, so
153 // the usage prints whole.
154 shape := cmd.Usage
155 if i := strings.Index(shape, " [--"); i >= 0 {
156 shape = shape[:i] + " [flags]"
157 }
158 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
159 fmt.Fprintln(w)
160 c.heading(w, "FLAGS")
161 rows := make([][2]string, 0, len(cmd.Flags)+1)
162 for _, f := range cmd.Flags {
163 name := f.Name
164 if f.Arg != "" {
165 name += " " + f.Arg
166 }
167 desc := f.Desc
168 if f.Default != "" {
169 desc += " (default " + f.Default + ")"
170 }
171 rows = append(rows, [2]string{name, desc})
172 }
173 rows = append(rows, [2]string{"--json", "machine-readable output"})
174 wide := 0
175 for _, r := range rows {
176 wide = max(wide, cells(r[0]))
177 }
178 for _, r := range rows {
179 c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1])
180 }
181 if len(cmd.Examples) > 0 {
182 fmt.Fprintln(w)
183 c.heading(w, "EXAMPLES")
184 for _, ex := range cmd.Examples {
185 c.wrapLine(w, " "+c.program()+" ", ex)
186 }
187 }
188 if len(below) > 0 {
189 fmt.Fprintln(w)
190 c.heading(w, "SEE ALSO")
191 for _, b := range below {
192 fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path))
193 }
194 }
195}
196
197func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
198 head := nounSummaries[strings.Fields(prefix)[0]]
199 fmt.Fprintln(w, head)
200 fmt.Fprintln(w)
201 c.heading(w, "USAGE")
202 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix)
203 wide := 0
204 for _, cmd := range cmds {
205 wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
206 }
207 for _, section := range []struct {
208 title string
209 read bool
210 }{{"READ", true}, {"WRITE", false}} {
211 first := true
212 for _, cmd := range cmds {
213 if cmd.ReadOnly != section.read {
214 continue
215 }
216 if first {
217 fmt.Fprintln(w)
218 c.heading(w, section.title)
219 first = false
220 }
221 verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")
222 fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary)
223 }
224 }
225 fmt.Fprintln(w)
226 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
227}