feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)

- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated.
- **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes.

## Breaking changes

This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement.

## Issues

fixes #1118
fixes #1116
fixes #1115
fixes #1094
fixes #1091
fixes #1073
fixes #1052
fixes #1032
fixes #1023
fixes #966
fixes #908
fixes #833
fixes #831
fixes #817
fixes #804
fixes #779
fixes #770
fixes #737
fixes #697
fixes #726
fixes #677
fixes #672
fixes #633
fixes #632
fixes #560
fixes #556
fixes #523
fixes #516
fixes #510
fixes #504
fixes #500
fixes #482
fixes #406
fixes #403
fixes #373
fixes #351
fixes #344
fixes #326
fixes #325
fixes #323
fixes #246
fixes #240
fixes #232
fixes #197
fixes #172
fixes #141
fixes #69
fixes #1110
fixes #569
fixes #480
fixes #666
fixes #670
fixes #457
fixes #643
fixes #498
fixes #490
fixes #1069
fixes #834
fixes #602
fixes #328
fixes #417
fixes #571
fixes #1029
fixes #489
fixes #1050
fixes #162
fixes #430
fixes #1087
fixes #683

Reviewed-on: https://gitea.com/gitea/tea/pulls/1126
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
func apiFields(raw, typed []string) (map[string]any, error) {
fields := map[string]any{}
for kind, arguments := range [][]string{raw, typed} {
for _, argument := range arguments {
key, value, ok := strings.Cut(argument, "=")
if !ok || strings.TrimSpace(key) == "" {
return nil, errors.New("API fields require name=value")
}
if _, exists := fields[key]; exists {
return nil, errors.New("API field names must be unique")
}
var parsed any = value
if kind == 1 {
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()
var candidate any
if err := decoder.Decode(&candidate); err == nil {
var extra any
if decoder.Decode(&extra) == io.EOF {
parsed = candidate
}
}
}
fields[key] = parsed
}
}
return fields, nil
}
func apiFieldFiles(fields map[string]any, typed []string, stdin io.Reader) error {
consumers := 0
for _, argument := range typed {
_, value, _ := strings.Cut(argument, "=")
if value == "@" {
return errors.New("file-backed API fields require @PATH or @-")
}
if value == "@-" {
consumers++
}
}
if consumers > 1 {
return errors.New("only one API field may read stdin")
}
for _, argument := range typed {
key, value, _ := strings.Cut(argument, "=")
if !strings.HasPrefix(value, "@") {
continue
}
filename := strings.TrimPrefix(value, "@")
if filename == "" {
return errors.New("file-backed API fields require @PATH or @-")
}
data, err := readFileOrStdin(stdin, filename)
if err != nil {
return err
}
fields[key] = string(data)
}
return nil
}
func apiHeaders(values []string) (http.Header, error) {
headers := http.Header{}
for _, argument := range values {
name, value, ok := strings.Cut(argument, ":")
if !ok || name == "" {
return nil, errors.New("API headers require Name: value")
}
for _, char := range name {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", char)) {
return nil, errors.New("invalid API header name")
}
}
for _, char := range value {
if (char < 32 && char != '\t') || char == 127 {
return nil, errors.New("invalid API header value")
}
}
switch strings.ToLower(name) {
case "authorization", "proxy-authorization", "host", "content-length", "transfer-encoding", "connection", "upgrade", "trailer", "te":
return nil, errors.New("API headers cannot override authentication, host, or transport framing")
}
headers.Add(name, strings.TrimSpace(value))
}
return headers, nil
}
func (a *application) apiCommand() *cobra.Command {
var method, input, output string
var raw, typed, headerValues []string
var include, paginate bool
var maxPages int
cmd := &cobra.Command{Use: "api <endpoint>", Short: "Make an authenticated Gitea API request", Annotations: textOutput("api returns raw responses"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
usage := func(err error) error { return clierr.Wrap(clierr.Usage, err) }
headers, err := apiHeaders(headerValues)
if err != nil {
return usage(err)
}
fields, err := apiFields(raw, typed)
if err != nil {
return usage(err)
}
if cmd.Flags().Changed("input") && input == "" {
return usage(errors.New("--input requires a file path or -"))
}
if input != "" && len(fields) > 0 {
return usage(errors.New("--input conflicts with API fields"))
}
if !cmd.Flags().Changed("method") && (input != "" || len(fields) > 0) {
method = http.MethodPost
}
method = strings.ToUpper(method)
if _, err := http.NewRequest(method, "http://localhost", nil); err != nil || method == "" {
return usage(errors.New("invalid HTTP method"))
}
if paginate && (method != http.MethodGet || input != "") {
return usage(errors.New("--paginate requires a body-free GET request"))
}
if maxPages < 1 {
return usage(errors.New("--max-pages must be positive"))
}
if output != "" {
if err := validateDownloadDestination(output); err != nil {
return err
}
}
if err := apiFieldFiles(fields, typed, a.deps.Streams.In); err != nil {
return usage(err)
}
var body io.Reader
if input == "-" {
body = a.deps.Streams.In
} else if input != "" {
file, err := os.Open(input)
if err != nil {
return err
}
defer file.Close()
body = file
}
endpoint := args[0]
if strings.ContainsAny(strings.NewReplacer("{owner}", "owner", "{repo}", "repo", "{branch}", "branch").Replace(endpoint), "{}") {
return usage(errors.New("unsupported API placeholder; use {owner}, {repo}, and {branch}"))
}
if strings.Contains(endpoint, "{branch}") {
branch, err := a.currentBranch(cmd.Context())
if err != nil || branch == "" {
return usage(errors.New("{branch} requires a checked-out Git branch"))
}
endpoint = strings.ReplaceAll(endpoint, "{branch}", url.PathEscape(branch))
}
requireRepo := strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}")
target, err := a.resolve(cmd.Context(), requireRepo)
if err != nil {
return err
}
if requireRepo {
pieces := strings.SplitN(target.Repo, "/", 2)
endpoint = strings.NewReplacer("{owner}", url.PathEscape(pieces[0]), "{repo}", url.PathEscape(pieces[1])).Replace(endpoint)
}
if len(fields) > 0 {
if method == http.MethodGet || method == http.MethodHead {
parsed, err := url.Parse(endpoint)
if err != nil {
return usage(errors.New("invalid API endpoint"))
}
query := parsed.Query()
for key, value := range fields {
if text, ok := value.(string); ok {
query.Set(key, text)
} else {
data, err := json.Marshal(value)
if err != nil {
return err
}
query.Set(key, string(data))
}
}
parsed.RawQuery = query.Encode()
endpoint = parsed.String()
} else {
data, err := json.Marshal(fields)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
}
return writeOutput(cmd, output, func(destination io.Writer) error {
return (services.API{Client: target.client}).Visit(cmd.Context(), services.APIOptions{Endpoint: endpoint, Method: method, Body: body, Headers: headers, Paginate: paginate, MaxPages: maxPages}, func(response *http.Response) error {
if include {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\n", response.Proto, response.Status); err != nil {
return err
}
keys := make([]string, 0, len(response.Header))
for key := range response.Header {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, value := range response.Header.Values(key) {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", key, value); err != nil {
return err
}
}
}
if _, err := fmt.Fprintln(cmd.ErrOrStderr()); err != nil {
return err
}
}
if _, err := io.Copy(destination, response.Body); err != nil {
return err
}
if paginate {
_, err := fmt.Fprintln(destination)
return err
}
return nil
})
})
}}
cmd.Flags().StringVarP(&method, "method", "X", "GET", "HTTP method")
cmd.Flags().StringVar(&input, "input", "", "Read request body from a file or stdin (-)")
cmd.Flags().StringArrayVarP(&headerValues, "header", "H", nil, "Request header: Name: value")
cmd.Flags().StringArrayVarP(&raw, "raw-field", "f", nil, "String request field: name=value")
cmd.Flags().StringArrayVarP(&typed, "field", "F", nil, "JSON request field: name=value; @PATH or @- reads file text")
cmd.Flags().StringVar(&output, "output-file", "", "Write the complete response to a new file")
cmd.Flags().BoolVar(&include, "include", false, "Print response status and headers to stderr")
cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow scoped next-page links; separate raw pages with newlines")
cmd.Flags().IntVar(&maxPages, "max-pages", 1000, "Maximum pages with --paginate")
return cmd
}