Files
gitea-tea/internal/app/app.go
T
bircni 29f62da006 refactor: load settings once per run and reuse shared helpers
The application caches the settings snapshot and reloads it only after
an update through the store, so output and prompt helpers no longer
read and parse config.yml and hosts.yml several times per command.
canPrompt and chooser replace the inline prompt-policy checks.

Checks are tallied by one services.Tally, terminal.Plural replaces the
two app copies, refKind.article/noun replace the comment group's own
words, list commands use servicePage, and web URLs are built by
repositoryURL and webURL in browser.go.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:57:39 +02:00

612 lines
22 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"
"sync"
"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
// snapshot caches the settings for one run; generation is the store's generation it was loaded at.
mu sync.Mutex
snapshot *settings.Snapshot
generation uint64
}
// settings returns the settings, loaded once per run and again after tea changed them.
func (a *application) settings() (settings.Snapshot, error) {
a.mu.Lock()
defer a.mu.Unlock()
generation := a.deps.Settings.Generation()
if a.snapshot != nil && a.generation == generation {
return *a.snapshot, nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return snapshot, err
}
a.snapshot, a.generation = &snapshot, generation
return snapshot, nil
}
// 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.settings()
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
}
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":
if cmd.Name() == "comment" {
return jsonFields[services.Comment]()
}
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]()
case "comment":
return jsonFields[services.Comment]()
}
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
}