feat: add PR diff, PR review, and structured error handling commands
This commit is contained in:
parent
3db03ed5e2
commit
50191cc542
10 changed files with 1008 additions and 13 deletions
260
cmd/api.go
Normal file
260
cmd/api.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/romaintb/fgj/internal/config"
|
||||
"codeberg.org/romaintb/fgj/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
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
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(os.Stdout, "%s %s\n", resp.Proto, resp.Status)
|
||||
for key, values := range resp.Header {
|
||||
for _, v := range values {
|
||||
fmt.Fprintf(os.Stdout, "%s: %s\n", key, v)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
|
||||
// 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(os.Stderr, string(respBody))
|
||||
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Raw output for non-JSON responses
|
||||
_, err = os.Stdout.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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue