Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling.
262 lines
7.2 KiB
Go
262 lines
7.2 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
|
"forgejo.zerova.net/sid/fgj-sid/internal/git"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var apiCmd = &cobra.Command{
|
|
Use: "api <endpoint> [flags]",
|
|
Short: "Make an authenticated API request",
|
|
Long: `Makes an authenticated HTTP request to the Forgejo API and prints the response.
|
|
|
|
The endpoint argument should be a path like "/repos/{owner}/{repo}/pulls".
|
|
Placeholders {owner} and {repo} are automatically replaced with values
|
|
detected from the current git repository.
|
|
|
|
If --field is used and no --method is specified, the method defaults to POST.`,
|
|
Example: ` # List pull requests for the current repository
|
|
fgj api /repos/{owner}/{repo}/pulls
|
|
|
|
# Create an issue
|
|
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
|
|
|
# Get a specific user
|
|
fgj api /users/johndoe
|
|
|
|
# Use raw body from stdin
|
|
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runAPI,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(apiCmd)
|
|
|
|
apiCmd.Flags().StringP("method", "X", "", "HTTP method (default: GET, or POST if --field is used)")
|
|
apiCmd.Flags().StringArrayP("field", "f", nil, "Add a typed field to the request body (key=value)")
|
|
apiCmd.Flags().StringArrayP("raw-field", "F", nil, "Add a string field to the request body (key=value)")
|
|
apiCmd.Flags().String("input", "", "Read request body from file (use \"-\" for stdin)")
|
|
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
|
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
|
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
|
}
|
|
|
|
func runAPI(cmd *cobra.Command, args []string) error {
|
|
endpoint := args[0]
|
|
|
|
method, _ := cmd.Flags().GetString("method")
|
|
fields, _ := cmd.Flags().GetStringArray("field")
|
|
rawFields, _ := cmd.Flags().GetStringArray("raw-field")
|
|
inputFile, _ := cmd.Flags().GetString("input")
|
|
headers, _ := cmd.Flags().GetStringArray("header")
|
|
hostname, _ := cmd.Flags().GetString("hostname")
|
|
silent, _ := cmd.Flags().GetBool("silent")
|
|
include, _ := cmd.Flags().GetBool("include")
|
|
|
|
// Resolve hostname and token from config
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
detectedHost := getDetectedHost()
|
|
|
|
host, err := cfg.GetHost(hostname, detectedHost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Interpolate {owner} and {repo} placeholders
|
|
if strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}") {
|
|
owner, repo, detectErr := git.DetectRepo()
|
|
if detectErr != nil {
|
|
return fmt.Errorf("cannot determine repository for path interpolation: %w", detectErr)
|
|
}
|
|
endpoint = strings.ReplaceAll(endpoint, "{owner}", owner)
|
|
endpoint = strings.ReplaceAll(endpoint, "{repo}", repo)
|
|
}
|
|
|
|
// Determine HTTP method
|
|
hasBody := len(fields) > 0 || len(rawFields) > 0 || inputFile != ""
|
|
if method == "" {
|
|
if hasBody {
|
|
method = http.MethodPost
|
|
} else {
|
|
method = http.MethodGet
|
|
}
|
|
}
|
|
method = strings.ToUpper(method)
|
|
|
|
// Build request body
|
|
var body io.Reader
|
|
if inputFile != "" {
|
|
if len(fields) > 0 || len(rawFields) > 0 {
|
|
return fmt.Errorf("--input cannot be combined with --field or --raw-field")
|
|
}
|
|
if inputFile == "-" {
|
|
body = os.Stdin
|
|
} else {
|
|
f, openErr := os.Open(inputFile)
|
|
if openErr != nil {
|
|
return fmt.Errorf("failed to open input file: %w", openErr)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
body = f
|
|
}
|
|
} else if len(fields) > 0 || len(rawFields) > 0 {
|
|
bodyMap := make(map[string]any)
|
|
|
|
for _, f := range fields {
|
|
key, value, parseErr := parseField(f, false)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
bodyMap[key] = value
|
|
}
|
|
for _, f := range rawFields {
|
|
key, value, parseErr := parseField(f, true)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
bodyMap[key] = value
|
|
}
|
|
|
|
bodyBytes, marshalErr := json.Marshal(bodyMap)
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("failed to marshal request body: %w", marshalErr)
|
|
}
|
|
body = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
// Build URL
|
|
baseURL := "https://" + host.Hostname + "/api/v1"
|
|
if !strings.HasPrefix(endpoint, "/") {
|
|
endpoint = "/" + endpoint
|
|
}
|
|
url := baseURL + endpoint
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set auth header
|
|
if host.Token != "" {
|
|
req.Header.Set("Authorization", "token "+host.Token)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if hasBody {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// Apply custom headers
|
|
for _, h := range headers {
|
|
key, value, found := strings.Cut(h, ":")
|
|
if !found {
|
|
return fmt.Errorf("invalid header format %q (expected key:value)", h)
|
|
}
|
|
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
|
}
|
|
|
|
// Execute request
|
|
ios.StartSpinner("Requesting...")
|
|
httpClient := &http.Client{}
|
|
resp, err := httpClient.Do(req)
|
|
ios.StopSpinner()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to perform request: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Print response headers if requested
|
|
if include {
|
|
fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status)
|
|
for key, values := range resp.Header {
|
|
for _, v := range values {
|
|
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
|
}
|
|
}
|
|
fmt.Fprintln(ios.Out)
|
|
}
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Handle non-2xx status codes
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
if !silent {
|
|
fmt.Fprint(ios.ErrOut, string(respBody))
|
|
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
|
fmt.Fprintln(ios.ErrOut)
|
|
}
|
|
}
|
|
return fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
if silent || len(respBody) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Pretty-print JSON, or output raw if not JSON
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "json") || json.Valid(respBody) {
|
|
var parsed any
|
|
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
|
enc := json.NewEncoder(ios.Out)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(parsed)
|
|
}
|
|
}
|
|
|
|
// Raw output for non-JSON responses
|
|
_, err = ios.Out.Write(respBody)
|
|
return err
|
|
}
|
|
|
|
// parseField parses a "key=value" string. When rawString is true, the value is
|
|
// always treated as a string. Otherwise, the function attempts JSON type
|
|
// inference: booleans ("true"/"false"), null, numbers, and falls back to string.
|
|
func parseField(field string, rawString bool) (string, any, error) {
|
|
key, value, found := strings.Cut(field, "=")
|
|
if !found {
|
|
return "", nil, fmt.Errorf("invalid field format %q (expected key=value)", field)
|
|
}
|
|
|
|
if rawString {
|
|
return key, value, nil
|
|
}
|
|
|
|
// JSON type inference
|
|
switch {
|
|
case value == "true":
|
|
return key, true, nil
|
|
case value == "false":
|
|
return key, false, nil
|
|
case value == "null":
|
|
return key, nil, nil
|
|
default:
|
|
// Try number
|
|
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
return key, n, nil
|
|
}
|
|
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
|
return key, f, nil
|
|
}
|
|
return key, value, nil
|
|
}
|
|
}
|