Files
gitea-tea/internal/app/app.go
T
bircni 78b5ff3a19 feat(release): show notes and assets, upload with --clobber, download like gh
release view printed "v0.1.0 v0.1.0" and a URL: the notes and assets
were only in --json. It now shows the title and tag, a Latest,
Pre-release or Draft badge, who released it when and the target, the
assets with their sizes and the notes, rendered on a terminal. Without
a tag it shows the latest release, and --web opens it.

release asset upload silently attached a second asset with the same
name; it now refuses unless --clobber replaces the existing one.
release delete printed the deleted release as a live row; it reports
"✓ Deleted release v2.0.0 ... (tag kept; use --delete-tag)".

release list shows TITLE, TYPE, TAG and PUBLISHED. release create takes
asset files after the tag, -t/-n/-F/-e/-d/-p (--body and --body-file
still work) and the new alias. Downloads select assets by name or ID or
with --pattern, default to every asset of the latest release, write to
./NAME, --dir or --output with --clobber/--skip-existing, and --archive
fetches the source code. release upload and release download are
shortcuts for the asset commands, asset edit and delete take names or
IDs, and the release JSON gains author and assets.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 23:52:32 +02:00

625 lines
23 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package app builds the tea command tree and its injected dependencies.
package app
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"
"time"
"unicode"
"gitea.dev/tea/internal/aliases"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"gitea.dev/tea/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Dependencies supplies external boundaries for a fresh CLI invocation.
type Dependencies struct {
Browser terminal.Browser
Editor terminal.Editor
Pager terminal.Pager
Executable func() (string, error)
Git git.Runner
Now func() time.Time
Settings *settings.Store
Credentials *auth.Store
Streams terminal.Streams
Prompter terminal.Prompter
IssueForm terminal.IssueForm
IssueEditForm terminal.IssueEditForm
PullForm terminal.PullForm
ReviewForm terminal.ReviewForm
MergeForm terminal.MergeForm
MilestoneForm terminal.MilestoneForm
Getenv func(string) string
Client func(string, string, bool) *transport.Client
}
// System connects the CLI to the current environment and terminal.
func System() Dependencies {
streams := terminal.System()
store := settings.New(os.Getenv("TEA_CONFIG_DIR"))
prompter := &terminal.FormPrompter{Streams: streams}
return Dependencies{Browser: terminal.SystemBrowser{ErrOut: streams.ErrOut}, Editor: terminal.SystemEditor{Streams: streams}, Pager: terminal.SystemPager{}, Executable: os.Executable, Git: git.System{In: streams.In, ErrOut: streams.ErrOut}, Now: time.Now, Settings: store, Credentials: &auth.Store{Dir: store.Dir, Keyring: auth.SystemKeyring{}}, Streams: streams, Prompter: prompter, IssueForm: prompter, IssueEditForm: prompter, PullForm: prompter, ReviewForm: prompter, MergeForm: prompter, MilestoneForm: prompter, Getenv: os.Getenv, Client: transport.New}
}
type options struct {
host, user, repo, cwd, remote string
sudo string
json, jq string
noInput, interactive, yes bool
debug bool
}
type application struct {
deps Dependencies
options options
}
// NewRoot builds fresh commands. Construction performs no disk, network, or Git IO.
func NewRoot(deps Dependencies) *cobra.Command {
if deps.Executable == nil {
deps.Executable = os.Executable
}
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Git == nil {
deps.Git = git.System{In: deps.Streams.In, ErrOut: deps.Streams.ErrOut}
}
if deps.Browser == nil {
deps.Browser = terminal.SystemBrowser{ErrOut: deps.Streams.ErrOut}
}
if deps.Editor == nil {
deps.Editor = terminal.SystemEditor{Streams: deps.Streams}
}
a := &application{deps: deps}
if client := deps.Client; client != nil {
a.deps.Client = func(instance, token string, insecure bool) *transport.Client {
if a.options.debug {
return client(instance, token, insecure).Debug(deps.Streams.ErrOut)
}
return client(instance, token, insecure)
}
}
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(cmd *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, suggestFlags(cmd, err)) })
flags := root.PersistentFlags()
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")
flags.BoolVar(&a.options.noInput, "no-input", false, "Disable prompts and implicit editor/browser interactions")
flags.BoolVar(&a.options.interactive, "interactive", false, "Allow prompts with machine output")
flags.BoolVarP(&a.options.yes, "yes", "y", false, "Accept confirmations without prompting")
flags.BoolVar(&a.options.debug, "debug", false, "Log the method, URL, status, and duration of each HTTP request to stderr")
_ = root.RegisterFlagCompletionFunc("host", a.completeHosts)
_ = root.RegisterFlagCompletionFunc("user", a.completeAccounts)
_ = root.RegisterFlagCompletionFunc("repo", a.completeRepos())
_ = root.RegisterFlagCompletionFunc("remote", a.completeRemotes)
_ = root.RegisterFlagCompletionFunc("cwd", completeDirectories)
_ = root.RegisterFlagCompletionFunc("json", completeJSONFields)
root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
if err := a.checkOutputFlags(cmd); err != nil {
return err
}
if err := validateEnums(cmd); err != nil {
return err
}
if cmd.Flags().Changed("sudo") {
if a.options.sudo == "" || strings.IndexFunc(a.options.sudo, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--sudo requires a username"))
}
for c := cmd; c.HasParent(); c = c.Parent() {
if c.Name() == "auth" && !c.Parent().HasParent() {
return clierr.Wrap(clierr.Usage, errors.New("--sudo cannot be used with auth commands"))
}
}
}
if offline(cmd) {
return nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
return a.policy(snapshot).Validate(a.deps.Streams)
}
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", 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()
completionInstallHelp(root)
root.SetVersionTemplate("{{.Version}}\n")
root.SetHelpFunc(a.writeHelp)
root.CompletionOptions.SetDefaultShellCompDirective(cobra.ShellCompDirectiveNoFileComp)
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)
completeDefaults(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"}
// addVerbAliases gives every list and delete command its short name.
func addVerbAliases(cmd *cobra.Command) {
if alias, ok := verbAliases[cmd.Name()]; ok && cmd.HasParent() && !slices.Contains(cmd.Aliases, alias) {
cmd.Aliases = append(cmd.Aliases, alias)
}
for _, child := range cmd.Commands() {
addVerbAliases(child)
}
}
// flagAliases makes the flags of cmd accept each key of aliases as another name of the flag it maps
// to, without showing it in help. Renamed flags keep their old names working this way, such as the
// plural --add-labels after the singular gh name --add-label became primary (D8).
func flagAliases(cmd *cobra.Command, aliases map[string]string) {
normalize := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(flags *pflag.FlagSet, name string) pflag.NormalizedName {
if to, ok := aliases[name]; ok {
name = to
}
return normalize(flags, name)
})
}
// suggestFlags adds the flags of cmd with a similar name to an unknown flag error, as in
// "unknown flag: --lable; did you mean --label?".
func suggestFlags(cmd *cobra.Command, err error) error {
name, unknown := strings.CutPrefix(err.Error(), "unknown flag: --")
if !unknown || len(name) < 3 {
return err
}
var suggestions []string
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if !flag.Hidden && (strings.HasPrefix(flag.Name, name) || editDistance(name, flag.Name) <= 2) {
suggestions = append(suggestions, "--"+flag.Name)
}
})
if len(suggestions) == 0 {
return err
}
return fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
// editDistance counts the single-character insertions, deletions and substitutions that turn a into b.
func editDistance(a, b string) int {
row := make([]int, len(b)+1)
for j := range row {
row[j] = j
}
for i := 1; i <= len(a); i++ {
diagonal := row[0]
row[0] = i
for j := 1; j <= len(b); j++ {
substitution := diagonal
if a[i-1] != b[j-1] {
substitution++
}
diagonal = row[j]
row[j] = min(row[j]+1, row[j-1]+1, substitution)
}
}
return row[len(b)]
}
// checkOutputFlags validates --json and --jq before a command runs. --jq alone selects every field,
// except on commands that filter their raw JSON themselves (annotation tea.rawjson).
func (a *application) checkOutputFlags(cmd *cobra.Command) error {
selected := ""
switch {
case cmd.Flags().Changed("json"):
if a.options.json == "" {
return clierr.Wrap(clierr.Usage, errors.New("--json= needs field names; use --json alone for every field"))
}
selected = "--json"
case a.options.jq != "" && cmd.Annotations["tea.rawjson"] == "":
selected, a.options.json = "--jq", "*"
}
if reason := cmd.Annotations["tea.text"]; reason != "" && selected != "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s; %s is not supported", reason, selected))
}
if flag := cmd.Flags().Lookup("web"); flag != nil && flag.Value.String() == "true" && (a.options.json != "" || a.options.jq != "") {
return clierr.Wrap(clierr.Usage, errors.New("--web cannot be combined with --json or --jq"))
}
return clierr.Wrap(clierr.Usage, terminal.ValidateOutput(a.options.json, a.options.jq, commandOutputFields(cmd)))
}
// offline reports commands that must work without valid settings.
func offline(cmd *cobra.Command) bool {
if cmd.Annotations["tea.group"] == "true" || cmd.Annotations["tea.offline"] == "true" {
return true
}
switch cmd.Name() {
case "help", "version", "man", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return true
}
return cmd.HasParent() && cmd.Parent().Name() == "completion"
}
// rejectUnknownSubcommands makes `tea bogus` and `tea issue bogus` usage errors instead of printing help.
func rejectUnknownSubcommands(cmd *cobra.Command) {
if cmd.HasSubCommands() && !cmd.Runnable() {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations["tea.group"] = "true"
cmd.Args, cmd.SuggestionsMinimumDistance = cobra.ArbitraryArgs, 2
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
err := fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 {
err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
return clierr.Wrap(clierr.Usage, err)
}
}
for _, child := range cmd.Commands() {
rejectUnknownSubcommands(child)
}
}
func builtins(root *cobra.Command) map[string]bool {
names := map[string]bool{"help": true, "completion": true}
for _, cmd := range root.Commands() {
if cmd.Annotations["tea.alias"] != "true" {
names[cmd.Name()] = true
}
}
return names
}
// Execute expands aliases and executes the fresh root without printing errors.
func Execute(ctx context.Context, deps Dependencies, args []string) error {
root := NewRoot(deps)
if at := commandIndex(root, args); at == len(args) || (args[at] != cobra.ShellCompRequestCmd && args[at] != cobra.ShellCompNoDescRequestCmd) {
args = joinJSONFields(root, args)
}
// Broken settings or aliases must not prevent help or recovery commands.
snapshot, loadErr := deps.Settings.Load()
names := builtins(root)
if commandAt := commandIndex(root, args); commandAt < len(args) {
name := args[commandAt]
completion := name == cobra.ShellCompRequestCmd || name == cobra.ShellCompNoDescRequestCmd
switch {
case loadErr != nil:
// An alias cannot be expanded without settings; report why instead of "unknown command".
if !names[name] && !completion {
return loadErr
}
case completion:
completionArgs := args[commandAt+1:]
if aliasAt := commandIndex(root, completionArgs); aliasAt < len(completionArgs) {
if expanded, err := aliases.ExpandCompletion(completionArgs[aliasAt:], snapshot.Preferences.Aliases, names); err == nil {
args = slices.Concat(args[:commandAt+1], completionArgs[:aliasAt], expanded)
}
}
default:
expanded, err := aliases.Expand(args[commandAt:], snapshot.Preferences.Aliases, names)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded))
}
if completion {
args = completeJSONValue(args)
}
}
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: 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"))
}})
}
}
}
root.SetArgs(args)
err := root.ExecuteContext(ctx)
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == 401 {
return clierr.Wrap(clierr.Authentication, err)
}
return err
}
var jsonFieldList = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(,[A-Za-z][A-Za-z0-9_]*)*$`)
// joinJSONFields accepts `--json number,title` like `--json=number,title`. A field-shaped word after a
// bare --json stays a positional argument only when the selected command does not output those fields
// and accepts the word as an argument, so a misspelled field reports the available fields rather than
// an argument count. A bare --json still selects every field. Unknown commands, such as aliases before
// expansion, are joined.
func joinJSONFields(root *cobra.Command, args []string) []string {
for i := 0; i < len(args); i++ {
if args[i] == "--" {
break
}
if args[i] != "--json" || i+1 >= len(args) || !jsonFieldList.MatchString(args[i+1]) {
continue
}
cmd, _, err := root.Find(slices.Concat(args[:i+1], args[i+2:]))
if err == nil && terminal.ValidateOutput(args[i+1], "", commandOutputFields(cmd)) != nil && acceptsArgs(args) {
continue
}
args = slices.Concat(args[:i], []string{"--json=" + args[i+1]}, args[i+2:])
}
return args
}
// acceptsArgs reports whether the command that args select accepts their positional arguments. It
// parses a separate command tree, because parsing sets the flags of the one that runs.
func acceptsArgs(args []string) bool {
cmd, rest, err := NewRoot(Dependencies{}).Find(args)
return err == nil && cmd.ParseFlags(rest) == nil && cmd.ValidateArgs(cmd.Flags().Args()) == nil
}
// confirm asks for confirmation under the prompt policy unless --yes was supplied.
func (a *application) confirm(cmd *cobra.Command, description string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
return terminal.Confirm(cmd.Context(), description, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
// confirmTyped asks the user to type answer before an irreversible action unless --yes was supplied.
func (a *application) confirmTyped(cmd *cobra.Command, description, answer string) error {
if a.options.yes {
return nil
}
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(s).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes"))
}
typed, err := a.deps.Prompter.Input(cmd.Context(), description+" Type "+answer+" to confirm:")
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(typed), answer) {
return clierr.Wrap(clierr.Canceled, errors.New("operation canceled"))
}
return nil
}
// require prompts for a missing required value under the prompt policy.
func (a *application) require(cmd *cobra.Command, value *string, flag string) (bool, error) {
s, err := a.deps.Settings.Load()
if err != nil {
return false, err
}
return terminal.Require(cmd.Context(), value, flag, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
func (a *application) policy(snapshot settings.Snapshot) terminal.Policy {
return terminal.Policy{Enabled: snapshot.Preferences.Prompt == "enabled", NoInput: a.options.noInput, Machine: a.options.json != "", Interactive: a.options.interactive}
}
// titleCase capitalizes an ASCII command word for help and status text.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// textOutput marks a command whose output is not a JSON resource, so --json is rejected up front.
func textOutput(reason string) map[string]string {
return map[string]string{"tea.text": reason}
}
// commandIndex skips persistent flags without parsing or mutating command state.
func commandIndex(root *cobra.Command, args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i + 1
}
if !strings.HasPrefix(arg, "-") || arg == "-" {
return i
}
name := strings.TrimLeft(arg, "-")
if strings.Contains(name, "=") {
continue
}
flag := root.PersistentFlags().Lookup(name)
if flag == nil && len(name) > 0 && !strings.HasPrefix(arg, "--") {
flag = root.PersistentFlags().ShorthandLookup(name[:1])
if len(name) > 1 {
continue
}
}
if flag != nil && flag.NoOptDefVal == "" {
i++
}
}
return len(args)
}
func commandOutputFields(cmd *cobra.Command) []string {
if cmd.Name() == "status" && cmd.Parent() != nil {
if cmd.Parent().Name() == "pr" {
return jsonFields[services.PullStatus]()
}
if cmd.Parent().Parent() == nil {
return jsonFields[services.AccountStatus]()
}
}
if cmd.Parent() == nil {
return nil
}
if cmd.Parent().Name() == "search" {
if cmd.Name() == "repo" {
return jsonFields[services.Repository]()
}
return jsonFields[services.SearchItem]()
}
switch cmd.Parent().Name() {
case "auth":
if cmd.Name() == "status" {
return jsonFields[authStatusRow]()
}
case "config":
if cmd.Name() == "get" || cmd.Name() == "list" {
return configKeys
}
case "tea":
if cmd.Name() == "doctor" {
return jsonFields[doctorReport]()
}
case "attachment":
return jsonFields[services.IssueAttachment]()
case "artifact":
return jsonFields[services.Artifact]()
case "user":
if cmd.Parent().Parent() != nil && cmd.Parent().Parent().Name() == "admin" {
return jsonFields[services.AdminUser]()
}
case "webhook":
return jsonFields[services.Webhook]()
case "branch":
return jsonFields[services.Branch]()
case "team":
return jsonFields[services.Team]()
case "notification":
return jsonFields[services.Notification]()
case "org":
return jsonFields[services.Organization]()
case "member":
return jsonFields[services.OrganizationMember]()
case "asset":
return jsonFields[services.ReleaseAsset]()
case "release":
if cmd.Name() == "upload" {
return jsonFields[services.ReleaseAsset]()
}
return jsonFields[services.Release]()
case "secret":
return jsonFields[services.ActionSecret]()
case "variable":
return jsonFields[services.ActionVariable]()
case "wiki":
if cmd.Name() == "revisions" {
return jsonFields[services.WikiRevision]()
}
return jsonFields[services.WikiPage]()
case "time":
return jsonFields[services.TrackedTime]()
case "ssh-key":
return jsonFields[services.SSHKey]()
case "comment":
return jsonFields[services.Comment]()
case "issue":
return jsonFields[services.Issue]()
case "review":
if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
return jsonFields[services.Pull]()
}
if cmd.Name() == "comments" || cmd.Name() == "reply" {
return jsonFields[services.ReviewComment]()
}
return jsonFields[services.Review]()
case "pr":
switch cmd.Name() {
case "checkout":
return jsonFields[git.CheckoutResult]()
case "checks":
return jsonFields[services.Check]()
case "review":
return jsonFields[services.Review]()
case "clean":
return jsonFields[git.CleanResult]()
}
return jsonFields[services.Pull]()
case "repo":
if cmd.Name() == "sync" {
return jsonFields[git.SyncResult]()
}
return jsonFields[services.Repository]()
case "label":
return jsonFields[services.Label]()
case "milestone":
return jsonFields[services.Milestone]()
case "workflow":
if cmd.Name() == "run" {
return jsonFields[services.DispatchResult]()
}
return jsonFields[services.Workflow]()
case "run":
if cmd.Name() == "jobs" {
return jsonFields[services.Job]()
}
return jsonFields[services.Run]()
}
return nil
}
// jsonFields lists the JSON names of T's fields, which --json can select, in alphabetical order.
func jsonFields[T any]() []string {
t := reflect.TypeFor[T]()
fields := make([]string, 0, t.NumField())
for field := range t.Fields() {
if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" && name != "-" {
fields = append(fields, name)
}
}
slices.Sort(fields)
return fields
}