internal/control/help.go

376 lines · 12981 bytes

19 symbols in this file
  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	"auth":          "whoami, SSH and PGP keys, email, API tokens",
 32	"build":         "CI builds",
 33	"dashboard":     "pinned repos, open MRs, assigned issues, recent builds",
 34	"email":         "manage email addresses",
 35	"explore":       "public repositories on this instance",
 36	"feed":          "activity on repositories you can reach",
 37	"help":          "list available commands",
 38	"issue":         "issues",
 39	"keys":          "manage SSH keys",
 40	"label":         "issue labels",
 41	"milestone":     "group issues and MRs toward a release",
 42	"mr":            "merge requests",
 43	"notifications": "your notification inbox",
 44	"org":           "organizations",
 45	"pgp":           "manage OpenPGP keys",
 46	"profile":       "user and org profiles",
 47	"register":      "create an account on this instance",
 48	"release":       "tag-anchored releases with notes and assets",
 49	"repo":          "create and manage repositories",
 50	"runner":        "the claim/report loop CI runners use",
 51	"search":        "find repositories, issues and merge requests",
 52	"query":         "saved issue and merge request queries across repositories",
 53	"snippet":       "shared text files, outside any repository",
 54	"status":        "commit statuses (CI)",
 55	"token":         "API tokens (minted over SSH, used with the JSON API)",
 56	"web":           "browser session",
 57	"webhook":       "outbound event delivery",
 58	"whoami":        "show the authenticated account",
 59	"wiki":          "a repository's wiki pages",
 60}
 61
 62// NounSummaries returns nounSummaries, for the CLI group-text agreement
 63// test (task 4.5).
 64func NounSummaries() map[string]string { return nounSummaries }
 65
 66// nounAlias is one bucket of registered commands, reachable under a
 67// CLI-only noun that is not itself a registry path (auth, gathering
 68// several unrelated registry prefixes): Registered is what runHelp
 69// matches against the registry, CLI is the path a gitbay caller
 70// actually types to reach it — not always Registered with the alias's
 71// own name stitched on (account export -> auth export drops a word),
 72// so the two are paired explicitly rather than derived.
 73type nounAlias struct {
 74	Registered string
 75	CLI        string
 76}
 77
 78// nounAliases groups a CLI-only noun into the real prefixes it gathers,
 79// so `help auth` renders with the same layout a real noun gets instead
 80// of falling back to whatever a caller does when help fails. A stock
 81// ssh caller — the only one who could ever ask for a bare "auth" and
 82// get nothing back from the registry — sees the Registered forms
 83// unchanged; the CLI, having sent its own path, sees CLI.
 84var nounAliases = map[string][]nounAlias{
 85	"auth": {
 86		{"account export", "auth export"},
 87		{"whoami", "auth whoami"},
 88		{"keys", "auth keys"},
 89		{"email", "auth email"},
 90		{"pgp", "auth pgp"},
 91		{"token", "auth token"},
 92	},
 93}
 94
 95// NounAliasNames returns the keys of nounAliases, for the CLI's own
 96// aliasGroupNames agreement test.
 97func NounAliasNames() []string {
 98	names := make([]string, 0, len(nounAliases))
 99	for name := range nounAliases {
100		names = append(names, name)
101	}
102	return names
103}
104
105// helpEntry is one row of the registry as help reports it.
106type helpEntry struct {
107	Path     string   `json:"path"`
108	Summary  string   `json:"summary"`
109	Usage    string   `json:"usage"`
110	Flags    []Flag   `json:"flags,omitempty"`
111	Examples []string `json:"examples,omitempty"`
112}
113
114// runHelp lists the registry, sorted, so a noun's commands sit together.
115// Bare `help` stays one line per command. A prefix that names one command
116// exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a
117// noun with several commands under it renders a READ/WRITE summary.
118func runHelp(c *Ctx, args []string) int {
119	prefix := joinPath(args)
120	prefixes := []string{prefix}
121	override := map[string]string{}
122	if aliased, ok := nounAliases[prefix]; ok {
123		prefixes = nil
124		for _, a := range aliased {
125			prefixes = append(prefixes, a.Registered)
126		}
127		if c.CLIPath != "" {
128			for _, cmd := range registry {
129				p := joinPath(cmd.Path)
130				for _, a := range aliased {
131					if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
132						override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
133						break
134					}
135				}
136			}
137		}
138	}
139	var matched []Command
140	for _, cmd := range registry {
141		p := joinPath(cmd.Path)
142		for _, pfx := range prefixes {
143			if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
144				matched = append(matched, cmd)
145				break
146			}
147		}
148	}
149	if len(matched) == 0 {
150		return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
151	}
152	slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
153	entries := make([]helpEntry, len(matched))
154	for i, cmd := range matched {
155		entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
156	}
157	return c.emit(entries, func(w io.Writer) {
158		switch {
159		case prefix == "":
160			for _, e := range entries {
161				summary := e.Summary
162				if c.Term.Cols > 0 {
163					if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
164						summary = clip(summary, avail)
165					}
166				}
167				fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
168			}
169		case joinPath(matched[0].Path) == prefix:
170			c.helpVerb(w, matched[0], matched[1:])
171		default:
172			c.helpNoun(w, prefix, matched, override)
173		}
174	})
175}
176
177// viaCLI reports whether the caller is the gitbay CLI, as far as the
178// session says: a terminal (only the CLI or a caller passing --term sets
179// one), or a CLI path, which only the CLI sends.
180func (c *Ctx) viaCLI() bool {
181	return c.Term.Cols > 0 || c.CLIPath != ""
182}
183
184// program is how help spells the command it documents: gitbay for the
185// CLI, ssh otherwise.
186func (c *Ctx) program() string {
187	if c.viaCLI() {
188		return "gitbay"
189	}
190	return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
191}
192
193// cliUsage marks a leading <owner/name> optional in a usage line for the
194// CLI, which infers it inside a clone (cmd/gitbay/ssh.go's withRepo).
195// Stock ssh never does.
196func cliUsage(usage string) string {
197	return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
198}
199
200// shownAs rewrites full, which starts with the registered path, to start
201// with the CLI's path instead when the CLI sent one that differs (#267).
202// The CLI path must name this command: either it regroups it (the same
203// last word, auth keys remove for keys remove) or extends it (repo
204// topics list for repo topics). Arguments after a CLI command can
205// dispatch to a longer registered path (gitbay repo topics list add
206// reaches repo topics add), and that command keeps its own name.
207func (c *Ctx) shownAs(registered, full string) string {
208	rest, ok := strings.CutPrefix(full, registered)
209	if !ok || c.CLIPath == "" || c.CLIPath == registered {
210		return full
211	}
212	reg, cli := strings.Fields(registered), strings.Fields(c.CLIPath)
213	if len(reg) == 0 || len(cli) == 0 || (cli[len(cli)-1] != reg[len(reg)-1] && !strings.HasPrefix(c.CLIPath, registered+" ")) {
214		return full
215	}
216	return c.CLIPath + rest
217}
218
219// shownBelow is how another command listed beside registered prints to
220// this caller. When the CLI only regrouped the command (auth keys remove
221// for keys remove, the same last word) the other command takes the CLI's
222// parent in place of the registered one. When the CLI renamed the last
223// word (repo topics list for repo topics) nothing follows about the
224// other command's name, so it keeps its registered path.
225func (c *Ctx) shownBelow(registered, other string) string {
226	reg, cli, o := strings.Fields(registered), strings.Fields(c.CLIPath), strings.Fields(other)
227	if len(cli) == 0 || len(reg) == 0 || len(o) < len(reg) || cli[len(cli)-1] != reg[len(reg)-1] ||
228		!slices.Equal(o[:len(reg)-1], reg[:len(reg)-1]) {
229		return other
230	}
231	return joinPath(append(slices.Clip(cli[:len(cli)-1]), o[len(reg)-1:]...))
232}
233
234// usageShape is a usage line for the command registered at path as this
235// caller should see it: the CLI's path in place of the registered one
236// where they differ, and a leading <owner/name> optional for the CLI.
237func (c *Ctx) usageShape(path []string, usage string) string {
238	shape := c.shownAs(joinPath(path), usage)
239	if c.viaCLI() {
240		shape = cliUsage(shape)
241	}
242	return shape
243}
244
245// cmdUsage is the running command's usage with the program in front, as
246// a usage refusal prints it.
247func (c *Ctx) cmdUsage() string {
248	return c.program() + " " + c.usageShape(c.Cmd.Path, c.Cmd.Usage)
249}
250
251func (c *Ctx) heading(w io.Writer, s string) {
252	fmt.Fprintln(w, c.Term.paint(sgrBold, s))
253}
254
255// wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging
256// indent under prefix when it would overflow. Plain output never wraps.
257func (c *Ctx) wrapLine(w io.Writer, prefix, text string) {
258	if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols {
259		fmt.Fprintln(w, prefix+text)
260		return
261	}
262	indent := cells(prefix)
263	width := c.Term.Cols - indent
264	for i, line := range termtext.Wrap(text, width) {
265		if i == 0 {
266			fmt.Fprintln(w, prefix+line)
267		} else {
268			fmt.Fprintln(w, strings.Repeat(" ", indent)+line)
269		}
270	}
271}
272
273func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
274	fmt.Fprintln(w, cmd.Summary)
275	fmt.Fprintln(w)
276	c.heading(w, "USAGE")
277	// Cutting at the first optional flag drops the rest of the usage
278	// syntax behind "[flags]" — safe only for what is actually optional.
279	// A required flag (repo delete --yes) or an alternative
280	// (notifications read <id>... | --all) has no " [--" to cut at, so
281	// the usage prints whole.
282	registered := joinPath(cmd.Path)
283	shape := c.usageShape(cmd.Path, cmd.Usage)
284	if i := strings.Index(shape, " [--"); i >= 0 {
285		shape = shape[:i] + " [flags]"
286	}
287	fmt.Fprintf(w, "  %s %s\n", c.program(), shape)
288	fmt.Fprintln(w)
289	c.heading(w, "FLAGS")
290	rows := make([][2]string, 0, len(cmd.Flags)+1)
291	for _, f := range cmd.Flags {
292		name := f.Name
293		if f.Arg != "" {
294			name += " " + f.Arg
295		}
296		desc := f.Desc
297		if f.Default != "" {
298			desc += " (default " + f.Default + ")"
299		}
300		rows = append(rows, [2]string{name, desc})
301	}
302	rows = append(rows, [2]string{"--json", "machine-readable output"})
303	wide := 0
304	for _, r := range rows {
305		wide = max(wide, cells(r[0]))
306	}
307	for _, r := range rows {
308		c.wrapLine(w, "  "+pad(r[0], wide)+"  ", r[1])
309	}
310	if len(cmd.Examples) > 0 {
311		fmt.Fprintln(w)
312		c.heading(w, "EXAMPLES")
313		for _, ex := range cmd.Examples {
314			c.wrapLine(w, "  "+c.program()+" ", c.shownAs(registered, ex))
315		}
316	}
317	if len(below) > 0 {
318		fmt.Fprintln(w)
319		c.heading(w, "SEE ALSO")
320		for _, b := range below {
321			fmt.Fprintf(w, "  %s %s\n", c.program(), c.shownBelow(registered, joinPath(b.Path)))
322		}
323	}
324}
325
326// helpNoun renders a noun with several commands under it. override, from
327// an aliased noun (auth), gives the full CLI path for a row that is not
328// itself under prefix (keys list, gathered under auth, becomes "auth
329// keys list"); it is empty for an ordinary noun, so every row there
330// still trims to just its own verb.
331func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
332	head := nounSummaries[strings.Fields(prefix)[0]]
333	fmt.Fprintln(w, head)
334	// An aliased noun is a CLI grouping: over stock ssh there is no
335	// "auth <verb>" to type, and each row already names its full command.
336	_, aliased := nounAliases[prefix]
337	bare := aliased && c.CLIPath == ""
338	display := c.shownAs(prefix, prefix)
339	if !bare {
340		fmt.Fprintln(w)
341		c.heading(w, "USAGE")
342		fmt.Fprintf(w, "  %s %s <verb> ...\n", c.program(), display)
343	}
344	rowText := func(cmd Command) string {
345		full := joinPath(cmd.Path)
346		if ov, ok := override[full]; ok {
347			return ov
348		}
349		return strings.TrimPrefix(full, prefix+" ")
350	}
351	wide := 0
352	for _, cmd := range cmds {
353		wide = max(wide, cells(rowText(cmd)))
354	}
355	for _, section := range []struct {
356		title string
357		read  bool
358	}{{"READ", true}, {"WRITE", false}} {
359		first := true
360		for _, cmd := range cmds {
361			if cmd.ReadOnly != section.read {
362				continue
363			}
364			if first {
365				fmt.Fprintln(w)
366				c.heading(w, section.title)
367				first = false
368			}
369			fmt.Fprintf(w, "  %s  %s\n", pad(rowText(cmd), wide), cmd.Summary)
370		}
371	}
372	if !bare {
373		fmt.Fprintln(w)
374		fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
375	}
376}