// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "errors" "fmt" "math" "os" "slices" "strconv" "strings" "time" "gitea.dev/tea/internal/clierr" "github.com/spf13/cobra" "github.com/spf13/pflag" ) // number parses a resource number written as N or #N. func number(raw string) (int64, error) { n, err := strconv.ParseInt(strings.TrimPrefix(raw, "#"), 10, 64) if err != nil || n < 1 { return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid resource number %q", raw)) } return n, nil } func resourceNumbers(args []string) ([]int64, error) { result := make([]int64, len(args)) seen := make(map[int64]bool, len(args)) for i, arg := range args { n, err := number(arg) if err != nil { return nil, err } if seen[n] { return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("resource %d is selected more than once; supply unique numbers", n)) } seen[n] = true result[i] = n } return result, nil } // requireNames trims name-list flag entries in place and rejects blanks; hint explains how to clear instead. func requireNames(kind, hint string, lists ...[]string) error { trimNames(lists...) if slices.Contains(slices.Concat(lists...), "") { return clierr.Wrap(clierr.Usage, fmt.Errorf("%s names cannot be empty%s", kind, hint)) } return nil } // trimNames removes surrounding whitespace from each entry in place, since Gitea rejects padded names. func trimNames(lists ...[]string) { for _, list := range lists { for i, name := range list { list[i] = strings.TrimSpace(name) } } } // labelFilter splits a comma-separated label filter, trimming entries and rejecting blanks. func labelFilter(raw string) ([]string, error) { if raw == "" { return nil, nil } names := strings.Split(raw, ",") return names, requireNames("label", "", names) } // oneOf rejects an enum flag value outside allowed, which needs at least two entries. func oneOf(flag, value string, allowed ...string) error { if slices.Contains(allowed, value) { return nil } last := len(allowed) - 1 choices := strings.Join(allowed[:last], ", ") if last > 1 { choices += "," } return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must be %s or %s", flag, choices, allowed[last])) } // maxLimit bounds --limit; list services page through the server's smaller pages to reach it. const maxLimit = 1000 // pageFlags registers -L/--limit and --page, which reject out-of-range values while parsing. func pageFlags(cmd *cobra.Command, limit, page *int) { *limit = 30 cmd.Flags().VarP(positiveInt{limit, maxLimit}, "limit", "L", "Maximum number of results (1..1000)") positiveIntVar(cmd, page, "page", 1, math.MaxInt, "Result page") } // positiveIntVar registers an int flag that rejects values outside 1..upper while parsing. func positiveIntVar(cmd *cobra.Command, value *int, name string, initial, upper int, usage string) { *value = initial cmd.Flags().Var(positiveInt{value, upper}, name, usage) } type positiveInt struct { value *int upper int } func (f positiveInt) String() string { return strconv.Itoa(*f.value) } func (f positiveInt) Type() string { return "int" } func (f positiveInt) Set(raw string) error { n, err := strconv.Atoi(raw) switch { case (err != nil || n < 1) && f.upper == math.MaxInt: return errors.New("must be a positive integer") case err != nil || n < 1 || n > f.upper: return fmt.Errorf("must be 1..%d", f.upper) } *f.value = n return nil } func parseRunTime(value string, now time.Time) (*time.Time, error) { if duration, err := time.ParseDuration(value); err == nil { if duration < 0 { return nil, errors.New("relative duration cannot be negative") } result := now.Add(-duration) return &result, nil } for _, format := range []string{time.RFC3339Nano, "2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05"} { if result, err := time.ParseInLocation(format, value, now.Location()); err == nil { return &result, nil } } return nil, errors.New("use a date, RFC3339 timestamp, or duration such as 24h") } // timeRange is a pair of lower and upper time bound flags. type timeRange struct { flags *pflag.FlagSet names [2]string values [2]string } // register adds the lower and upper bound flags to cmd. func (r *timeRange) register(cmd *cobra.Command, lower, lowerUsage, upper, upperUsage string) { r.flags, r.names = cmd.Flags(), [2]string{lower, upper} r.flags.StringVar(&r.values[0], lower, "", lowerUsage) r.flags.StringVar(&r.values[1], upper, "", upperUsage) } // parse returns the bounds that were set and rejects inverted ranges. func (r *timeRange) parse(now time.Time) (since, until *time.Time, err error) { var bounds [2]*time.Time for i, flag := range r.names { if !r.flags.Changed(flag) { continue } if bounds[i], err = parseRunTime(r.values[i], now); err != nil { return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --%s: %w", flag, err)) } } if bounds[0] != nil && bounds[1] != nil && bounds[0].After(*bounds[1]) { return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must not be later than --%s", r.names[0], r.names[1])) } return bounds[0], bounds[1], nil } // openRegularFiles opens upload sources, rejecting anything but regular files. // Callers close the returned files even on error. func openRegularFiles(paths []string) ([]*os.File, error) { files := make([]*os.File, 0, len(paths)) for _, path := range paths { file, err := os.Open(path) if err != nil { return files, err } files = append(files, file) info, err := file.Stat() if err != nil { return files, err } if !info.Mode().IsRegular() { return files, clierr.Wrap(clierr.Usage, fmt.Errorf("%s is not a regular file", path)) } } return files, nil } func closeFiles(files []*os.File) { for _, file := range files { _ = file.Close() } } func argsExact(n int) cobra.PositionalArgs { return usageArgs(cobra.ExactArgs(n)) } // usageArgs reports positional argument errors with the usage exit code, the usage line and a // pointer to the command's help. func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { err := validate(cmd, args) if err == nil { return nil } return clierr.Wrap(clierr.Usage, &argumentError{problem: argumentProblem(cmd, args, err), cmd: cmd}) } } // argumentError reports a positional argument problem followed by the command's usage. type argumentError struct { problem string cmd *cobra.Command } func (e *argumentError) Error() string { more := "for more information" if e.cmd.Example != "" { more = "for examples" } return fmt.Sprintf("%s\nUsage: %s\nRun '%s --help' %s.", e.problem, e.cmd.UseLine(), e.cmd.CommandPath(), more) } // argumentProblem names what is wrong with args from the arguments in the command's Use, such // as "missing issue number", falling back to the validator's message. func argumentProblem(cmd *cobra.Command, args []string, err error) string { arguments := usageArguments(cmd.Use) required, variadic := 0, false for _, argument := range arguments { if !strings.HasPrefix(argument, "[") { required++ } variadic = variadic || strings.HasSuffix(strings.TrimRight(argument, "]}>"), "...") } switch { case len(args) < required: return "missing " + argumentName(cmd, arguments[len(args)]) case len(arguments) == 0 && len(args) > 0: return fmt.Sprintf("unexpected argument %q", args[0]) case !variadic && len(arguments) > 0 && len(args) > len(arguments): return "too many arguments" } return err.Error() } // usageArguments splits the arguments of a Use line, such as "view ", into its // top-level groups: "", "[ | ]" or "...". [flags] is not one. func usageArguments(use string) []string { _, rest, _ := strings.Cut(use, " ") var arguments []string depth, start := 0, -1 for i, r := range rest { switch { case strings.ContainsRune("<[{", r): if depth == 0 { start = i } depth++ case strings.ContainsRune(">]}", r): depth-- } if depth == 0 && start >= 0 && (i == len(rest)-1 || rest[i+1] == ' ') { if argument := rest[start : i+1]; argument != "[flags]" { arguments = append(arguments, argument) } start = -1 } } return arguments } // argumentName words a Use argument for an error: "" is "asset ID", and a bare // "" or "" names the command group's resource, as in "issue number". func argumentName(cmd *cobra.Command, argument string) string { first, _, _ := strings.Cut(argument, "|") name := strings.Trim(first, "<>[]{}. ") words := strings.Split(name, "-") for i, word := range words { if word == "id" { words[i] = "ID" } } text := strings.Join(words, " ") if name != "number" && name != "id" && name != "id-or-name" { return text } group := cmd for group.HasParent() && group.Parent().HasParent() { group = group.Parent() } if group == cmd { return text } if noun := group.Name(); noun == "pr" { return "pull request " + text } return group.Name() + " " + text } // localFlagsChanged reports whether any command-specific flag was supplied. func localFlagsChanged(cmd *cobra.Command) bool { changed := false cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { changed = changed || flag.Changed }) return changed }