mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
revert: tea 1.0 rewrite (#1122–#1126) (#1146)
Reverts #1126, #1125, #1124, #1123 and #1122. `go.mod` keeps the Go 1.26 line with toolchain `go1.26.9` (instead of the original `go1.26.6`) so the security fix from #1131 isn't lost. reopens #69 reopens #141 reopens #162 reopens #172 reopens #197 reopens #232 reopens #240 reopens #246 reopens #323 reopens #325 reopens #326 reopens #328 reopens #344 reopens #351 reopens #373 reopens #403 reopens #406 reopens #417 reopens #430 reopens #457 reopens #480 reopens #482 reopens #489 reopens #490 reopens #498 reopens #500 reopens #504 reopens #510 reopens #516 reopens #523 reopens #556 reopens #560 reopens #569 reopens #571 reopens #602 reopens #632 reopens #633 reopens #643 reopens #666 reopens #670 reopens #672 reopens #677 reopens #683 reopens #697 reopens #726 reopens #737 reopens #770 reopens #779 reopens #804 reopens #817 reopens #831 reopens #833 reopens #834 reopens #908 reopens #966 reopens #1023 reopens #1029 reopens #1032 reopens #1050 reopens #1052 reopens #1069 reopens #1073 reopens #1087 reopens #1091 reopens #1094 reopens #1110 reopens #1115 reopens #1116 reopens #1118 Reviewed-on: https://gitea.com/gitea/tea/pulls/1146 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
33bb12729e
commit
c5f674111a
604 files changed
+33711
-35545
No files matched your search
+408
@@ -0,0 +1,408 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/cmd/flags"
|
||||
"gitea.dev/tea/modules/api"
|
||||
"gitea.dev/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// apiFlags returns a fresh set of flag instances for the api command.
|
||||
// This is a factory function so that each invocation gets independent flag
|
||||
// objects, avoiding shared hasBeenSet state across tests.
|
||||
func apiFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "method",
|
||||
Aliases: []string{"X"},
|
||||
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
|
||||
Value: "GET",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "field",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Add a string field to the request body (key=value)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "Field",
|
||||
Aliases: []string{"F"},
|
||||
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "header",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Add a custom header (key:value)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Raw JSON request body (use @file to read from file, @- for stdin)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Include HTTP status and response headers in output (written to stderr)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CmdApi represents the api command
|
||||
var CmdApi = cli.Command{
|
||||
Name: "api",
|
||||
Category: catHelpers,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Usage: "Make an authenticated API request",
|
||||
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
|
||||
|
||||
The endpoint argument is the path to the API endpoint, which will be prefixed
|
||||
with /api/v1/ if it doesn't start with /api/ or http(s)://.
|
||||
|
||||
Placeholders like {owner} and {repo} in the endpoint will be replaced with
|
||||
values from the current repository context.
|
||||
|
||||
Use -f for string fields and -F for typed fields (numbers, booleans, null).
|
||||
With -F, prefix value with @ to read from file (@- for stdin). Values starting
|
||||
with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force
|
||||
string type (e.g., -F key="null" for literal string "null").
|
||||
|
||||
Use -d/--data to send a raw JSON body. Use @file to read from a file, or @-
|
||||
to read from stdin. The -d flag cannot be combined with -f or -F.
|
||||
|
||||
When a request body is provided via -f, -F, or -d, the method defaults to POST
|
||||
unless explicitly set with -X/--method.
|
||||
|
||||
Note: if your endpoint contains ? or &, quote it to prevent shell expansion
|
||||
(e.g., '/repos/{owner}/{repo}/issues?state=open').`,
|
||||
ArgsUsage: "<endpoint>",
|
||||
Action: runApi,
|
||||
Flags: append(apiFlags(), flags.LoginRepoFlags...),
|
||||
}
|
||||
|
||||
type preparedAPIRequest struct {
|
||||
Method string
|
||||
Endpoint string
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func runApi(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := prepareAPIRequest(cmd, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if request.Body != nil {
|
||||
body = bytes.NewReader(request.Body)
|
||||
}
|
||||
|
||||
// Create API client and make request
|
||||
client := api.NewClient(ctx.Login)
|
||||
resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Print headers to stderr if requested (so redirects/pipes work correctly)
|
||||
if cmd.Bool("include") {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status)
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
|
||||
// Determine output destination
|
||||
outputPath := cmd.String("output")
|
||||
forceStdout := outputPath == "-"
|
||||
outputToStdout := outputPath == "" || forceStdout
|
||||
|
||||
// Check for binary output to terminal (skip warning if user explicitly forced stdout)
|
||||
if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) {
|
||||
fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o <file>' to save to a file,")
|
||||
fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var output io.Writer = os.Stdout
|
||||
if !outputToStdout {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr)
|
||||
}
|
||||
}()
|
||||
output = file
|
||||
}
|
||||
|
||||
// Copy response body to output
|
||||
_, err = io.Copy(output, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Add newline for better terminal display
|
||||
if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareAPIRequest(cmd *cli.Command, ctx *context.TeaContext) (*preparedAPIRequest, error) {
|
||||
var err error
|
||||
|
||||
// Get the endpoint argument
|
||||
if cmd.NArg() < 1 {
|
||||
return nil, fmt.Errorf("endpoint argument required")
|
||||
}
|
||||
endpoint := cmd.Args().First()
|
||||
|
||||
// Expand placeholders in endpoint
|
||||
endpoint = expandPlaceholders(endpoint, ctx)
|
||||
|
||||
// Parse headers
|
||||
headers := make(map[string]string)
|
||||
for _, h := range cmd.StringSlice("header") {
|
||||
parts := strings.SplitN(h, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid header format: %q (expected key:value)", h)
|
||||
}
|
||||
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
// Build request body from fields
|
||||
var bodyBytes []byte
|
||||
stringFields := cmd.StringSlice("field")
|
||||
typedFields := cmd.StringSlice("Field")
|
||||
dataRaw := cmd.String("data")
|
||||
|
||||
if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) {
|
||||
return nil, fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F")
|
||||
}
|
||||
|
||||
if dataRaw != "" {
|
||||
var dataBytes []byte
|
||||
var dataSource string
|
||||
if strings.HasPrefix(dataRaw, "@") {
|
||||
filename := dataRaw[1:]
|
||||
if filename == "-" {
|
||||
dataBytes, err = io.ReadAll(os.Stdin)
|
||||
dataSource = "stdin"
|
||||
} else {
|
||||
dataBytes, err = os.ReadFile(filename)
|
||||
dataSource = filename
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %q: %w", dataRaw, err)
|
||||
}
|
||||
} else {
|
||||
dataBytes = []byte(dataRaw)
|
||||
}
|
||||
if !json.Valid(dataBytes) {
|
||||
if dataSource != "" {
|
||||
return nil, fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource)
|
||||
}
|
||||
return nil, fmt.Errorf("--data/-d value is not valid JSON")
|
||||
}
|
||||
bodyBytes = dataBytes
|
||||
} else if len(stringFields) > 0 || len(typedFields) > 0 {
|
||||
bodyMap := make(map[string]any)
|
||||
|
||||
// Process string fields (-f)
|
||||
for _, f := range stringFields {
|
||||
parts := strings.SplitN(f, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f)
|
||||
}
|
||||
key := parts[0]
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("field key cannot be empty in %q", f)
|
||||
}
|
||||
if _, exists := bodyMap[key]; exists {
|
||||
return nil, fmt.Errorf("duplicate field key %q", key)
|
||||
}
|
||||
bodyMap[key] = parts[1]
|
||||
}
|
||||
|
||||
// Process typed fields (-F)
|
||||
for _, f := range typedFields {
|
||||
parts := strings.SplitN(f, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f)
|
||||
}
|
||||
key := parts[0]
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("field key cannot be empty in %q", f)
|
||||
}
|
||||
if _, exists := bodyMap[key]; exists {
|
||||
return nil, fmt.Errorf("duplicate field key %q", key)
|
||||
}
|
||||
value := parts[1]
|
||||
|
||||
parsedValue, err := parseTypedValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse field %q: %w", key, err)
|
||||
}
|
||||
bodyMap[key] = parsedValue
|
||||
}
|
||||
|
||||
bodyBytes, err = json.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode request body: %w", err)
|
||||
}
|
||||
}
|
||||
method := strings.ToUpper(cmd.String("method"))
|
||||
if !cmd.IsSet("method") {
|
||||
if bodyBytes != nil {
|
||||
method = "POST"
|
||||
} else {
|
||||
method = "GET"
|
||||
}
|
||||
}
|
||||
|
||||
return &preparedAPIRequest{
|
||||
Method: method,
|
||||
Endpoint: endpoint,
|
||||
Headers: headers,
|
||||
Body: bodyBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseTypedValue parses a value for -F flag, handling:
|
||||
// - @filename: read content from file
|
||||
// - @-: read content from stdin
|
||||
// - "quoted": literal string (prevents type parsing)
|
||||
// - true/false: boolean
|
||||
// - null: nil
|
||||
// - numbers: int or float
|
||||
// - []/{}: JSON arrays/objects
|
||||
// - otherwise: string
|
||||
func parseTypedValue(value string) (any, error) {
|
||||
// Handle file references.
|
||||
// Note: if multiple fields use @- (stdin), only the first will get data;
|
||||
// subsequent reads will return empty since stdin is consumed once.
|
||||
if strings.HasPrefix(value, "@") {
|
||||
filename := value[1:]
|
||||
var content []byte
|
||||
var err error
|
||||
|
||||
if filename == "-" {
|
||||
content, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
content, err = os.ReadFile(filename)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %q: %w", value, err)
|
||||
}
|
||||
return strings.TrimSuffix(string(content), "\n"), nil
|
||||
}
|
||||
|
||||
// Handle quoted strings (literal strings, no type parsing).
|
||||
// Uses strconv.Unquote so escape sequences like \" are handled correctly.
|
||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||
unquoted, err := strconv.Unquote(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid quoted string %s: %w", value, err)
|
||||
}
|
||||
return unquoted, nil
|
||||
}
|
||||
|
||||
// Handle null
|
||||
if value == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Handle booleans
|
||||
if value == "true" {
|
||||
return true, nil
|
||||
}
|
||||
if value == "false" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Handle integers
|
||||
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Handle floats
|
||||
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Handle JSON arrays and objects
|
||||
if len(value) > 0 && (value[0] == '[' || value[0] == '{') {
|
||||
var jsonVal any
|
||||
if err := json.Unmarshal([]byte(value), &jsonVal); err == nil {
|
||||
return jsonVal, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Default to string
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// isTextContentType returns true if the content type indicates text data
|
||||
func isTextContentType(contentType string) bool {
|
||||
if contentType == "" {
|
||||
return true // assume text if unknown
|
||||
}
|
||||
contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset
|
||||
|
||||
return strings.HasPrefix(contentType, "text/") ||
|
||||
strings.Contains(contentType, "json") ||
|
||||
strings.Contains(contentType, "xml") ||
|
||||
strings.Contains(contentType, "javascript") ||
|
||||
strings.Contains(contentType, "yaml") ||
|
||||
strings.Contains(contentType, "toml")
|
||||
}
|
||||
|
||||
// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint
|
||||
func expandPlaceholders(endpoint string, ctx *context.TeaContext) string {
|
||||
endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner)
|
||||
endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo)
|
||||
|
||||
// Get current branch if available
|
||||
if ctx.LocalRepo != nil {
|
||||
if branch, err := ctx.LocalRepo.Head(); err == nil {
|
||||
branchName := branch.Name().Short()
|
||||
endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName)
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint
|
||||
}
|
||||
Reference in new issue
Block a user