feat: add PR diff, PR review, and structured error handling commands

This commit is contained in:
sid 2026-03-21 21:50:24 -06:00
parent 3db03ed5e2
commit 50191cc542
10 changed files with 1008 additions and 13 deletions

260
cmd/api.go Normal file
View 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
}
}

76
cmd/errors.go Normal file
View file

@ -0,0 +1,76 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"codeberg.org/romaintb/fgj/internal/api"
)
// Error codes for structured error output.
const (
ErrAuthRequired = "auth_required"
ErrNotFound = "not_found"
ErrAPIError = "api_error"
ErrInvalidInput = "invalid_input"
ErrGitDetectionFailed = "git_detection_failed"
ErrNetworkError = "network_error"
)
// CLIError is a structured error type for machine-readable output.
type CLIError struct {
Code string `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Status int `json:"status,omitempty"`
}
func (e *CLIError) Error() string {
return e.Message
}
// NewCLIError creates a new CLIError with the given code and message.
func NewCLIError(code, message string) *CLIError {
return &CLIError{Code: code, Message: message}
}
// NewAPIError creates a CLIError from an HTTP status and message.
func NewAPIError(status int, message string) *CLIError {
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
}
// writeJSONError writes a structured JSON error to stderr.
// It attempts to extract structured info from known error types.
// WriteJSONError writes a structured JSON error to stderr.
// It is exported for use from main.go.
func WriteJSONError(err error) {
cliErr := &CLIError{
Code: ErrAPIError,
Message: err.Error(),
}
// Try to extract structured info from the error chain.
var apiErr *api.APIError
var cErr *CLIError
switch {
case errors.As(err, &cErr):
cliErr = cErr
case errors.As(err, &apiErr):
cliErr.Status = apiErr.StatusCode
cliErr.Detail = apiErr.Body
switch {
case apiErr.StatusCode == 401 || apiErr.StatusCode == 403:
cliErr.Code = ErrAuthRequired
case apiErr.StatusCode == 404:
cliErr.Code = ErrNotFound
default:
cliErr.Code = ErrAPIError
}
}
enc := json.NewEncoder(os.Stderr)
enc.SetIndent("", " ")
_ = enc.Encode(cliErr)
}

271
cmd/pr_diff.go Normal file
View file

@ -0,0 +1,271 @@
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var prDiffCmd = &cobra.Command{
Use: "diff <number>",
Short: "Show the diff for a pull request",
Long: "Fetch and display the diff for a pull request.",
Example: ` # View the diff for PR #123
fgj pr diff 123
# Colorized diff output
fgj pr diff 123 --color always
# Show only changed file names
fgj pr diff 123 --name-only
# Show diffstat summary
fgj pr diff 123 --stat`,
Args: cobra.ExactArgs(1),
RunE: runPRDiff,
}
func init() {
prCmd.AddCommand(prDiffCmd)
prDiffCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prDiffCmd.Flags().String("color", "auto", "Colorize the diff output: auto, always, never")
prDiffCmd.Flags().Bool("name-only", false, "Show only changed file names")
prDiffCmd.Flags().Bool("stat", false, "Show diffstat summary")
}
func runPRDiff(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
colorMode, _ := cmd.Flags().GetString("color")
nameOnly, _ := cmd.Flags().GetBool("name-only")
stat, _ := cmd.Flags().GetBool("stat")
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
client.Hostname(), owner, name, prNumber)
diff, err := client.GetRawLog(diffURL)
if err != nil {
return fmt.Errorf("failed to get pull request diff: %w", err)
}
if nameOnly {
return printNameOnly(diff)
}
if stat {
return printDiffStat(diff)
}
useColor := shouldColorize(colorMode)
if useColor {
return printColorizedDiff(diff)
}
fmt.Print(diff)
return nil
}
// shouldColorize determines whether to colorize output based on the color flag.
func shouldColorize(mode string) bool {
switch strings.ToLower(mode) {
case "always":
return true
case "never":
return false
default: // "auto"
return term.IsTerminal(int(os.Stdout.Fd()))
}
}
// printNameOnly extracts and prints changed file names from the diff.
func printNameOnly(diff string) error {
seen := make(map[string]bool)
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "+++ b/") {
name := strings.TrimPrefix(line, "+++ b/")
if name != "" && !seen[name] {
seen[name] = true
fmt.Println(name)
}
}
}
return nil
}
// fileStat holds per-file diff statistics.
type fileStat struct {
name string
additions int
deletions int
}
// printDiffStat parses the diff and prints a diffstat summary.
func printDiffStat(diff string) error {
var stats []fileStat
var current *fileStat
inHeader := true
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "diff --git ") {
if current != nil {
stats = append(stats, *current)
}
current = &fileStat{}
inHeader = true
continue
}
if current == nil {
continue
}
if strings.HasPrefix(line, "+++ b/") {
current.name = strings.TrimPrefix(line, "+++ b/")
continue
}
if strings.HasPrefix(line, "@@") {
inHeader = false
continue
}
if inHeader {
continue
}
if strings.HasPrefix(line, "+") {
current.additions++
} else if strings.HasPrefix(line, "-") {
current.deletions++
}
}
if current != nil && current.name != "" {
stats = append(stats, *current)
}
if len(stats) == 0 {
fmt.Println("0 files changed")
return nil
}
// Find the longest file name for alignment
maxNameLen := 0
maxChanges := 0
for _, s := range stats {
if len(s.name) > maxNameLen {
maxNameLen = len(s.name)
}
total := s.additions + s.deletions
if total > maxChanges {
maxChanges = total
}
}
// Cap the bar width
barWidth := maxChanges
if barWidth > 50 {
barWidth = 50
}
totalAdditions := 0
totalDeletions := 0
for _, s := range stats {
total := s.additions + s.deletions
totalAdditions += s.additions
totalDeletions += s.deletions
var bar string
if maxChanges > 0 {
scaledAdd := s.additions
scaledDel := s.deletions
if maxChanges > 50 {
scaledAdd = s.additions * 50 / maxChanges
scaledDel = s.deletions * 50 / maxChanges
if s.additions > 0 && scaledAdd == 0 {
scaledAdd = 1
}
if s.deletions > 0 && scaledDel == 0 {
scaledDel = 1
}
}
bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel)
}
fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
}
fmt.Printf(" %d file", len(stats))
if len(stats) != 1 {
fmt.Print("s")
}
fmt.Printf(" changed, %d insertion", totalAdditions)
if totalAdditions != 1 {
fmt.Print("s")
}
fmt.Printf("(+), %d deletion", totalDeletions)
if totalDeletions != 1 {
fmt.Print("s")
}
fmt.Println("(-)")
return nil
}
// ANSI color codes for diff output.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorCyan = "\033[36m"
colorBold = "\033[1m"
)
// printColorizedDiff prints the diff with ANSI color codes.
func printColorizedDiff(diff string) error {
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "diff --git "):
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "--- "),
strings.HasPrefix(line, "+++ "),
strings.HasPrefix(line, "new file"),
strings.HasPrefix(line, "deleted file"),
strings.HasPrefix(line, "similarity index"),
strings.HasPrefix(line, "rename from"),
strings.HasPrefix(line, "rename to"):
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "@@"):
fmt.Println(colorCyan + line + colorReset)
case strings.HasPrefix(line, "+"):
fmt.Println(colorGreen + line + colorReset)
case strings.HasPrefix(line, "-"):
fmt.Println(colorRed + line + colorReset)
default:
fmt.Println(line)
}
}
return nil
}

229
cmd/pr_review.go Normal file
View file

@ -0,0 +1,229 @@
package cmd
import (
"fmt"
"io"
"os"
"strconv"
"code.gitea.io/sdk/gitea"
"codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
)
var prCommentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Add a comment to a pull request",
Long: "Add a comment to an existing pull request.",
Example: ` # Add a comment
fgj pr comment 123 -b "Looks good!"
# Comment from a file
fgj pr comment 123 --body-file review-notes.md
# Comment from stdin
echo "LGTM" | fgj pr comment 123 --body-file -
# Output as JSON
fgj pr comment 123 -b "Nice work" --json`,
Args: cobra.ExactArgs(1),
RunE: runPRComment,
}
var prReviewCmd = &cobra.Command{
Use: "review <number>",
Short: "Submit a review on a pull request",
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
Example: ` # Approve a PR
fgj pr review 123 --approve -b "LGTM"
# Request changes
fgj pr review 123 --request-changes -b "Please fix the error handling"
# Submit a review comment
fgj pr review 123 --comment -b "Some observations"
# Request changes with body from file
fgj pr review 123 --request-changes --body-file feedback.md`,
Args: cobra.ExactArgs(1),
RunE: runPRReview,
}
func init() {
prCmd.AddCommand(prCommentCmd)
prCmd.AddCommand(prReviewCmd)
prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCommentCmd.Flags().StringP("body", "b", "", "Comment body")
prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON")
prReviewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request")
prReviewCmd.Flags().BoolP("request-changes", "r", false, "Request changes on the pull request")
prReviewCmd.Flags().BoolP("comment", "c", false, "Submit as a review comment")
prReviewCmd.Flags().StringP("body", "b", "", "Review body/message")
prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
prReviewCmd.Flags().Bool("json", false, "Output created review as JSON")
}
// readBody resolves the body text from --body and --body-file flags.
// --body takes precedence; if --body-file is set and --body is empty, the file
// (or stdin when the path is "-") is read instead.
func readBody(cmd *cobra.Command) (string, error) {
body, _ := cmd.Flags().GetString("body")
bodyFile, _ := cmd.Flags().GetString("body-file")
if body != "" && bodyFile != "" {
return "", fmt.Errorf("use either --body or --body-file, not both")
}
if bodyFile != "" {
var data []byte
var err error
if bodyFile == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(bodyFile)
}
if err != nil {
return "", fmt.Errorf("failed to read body file: %w", err)
}
body = string(data)
}
return body, nil
}
func runPRComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
body, err := readBody(cmd)
if err != nil {
return err
}
if body == "" {
return fmt.Errorf("comment body is required (use --body or --body-file)")
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
Body: body,
})
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(comment)
}
fmt.Printf("Comment added to PR #%d\n", prNumber)
fmt.Printf("View at: %s\n", comment.HTMLURL)
return nil
}
func runPRReview(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
approve, _ := cmd.Flags().GetBool("approve")
requestChanges, _ := cmd.Flags().GetBool("request-changes")
commentReview, _ := cmd.Flags().GetBool("comment")
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
// Validate exactly one review type is specified
count := 0
if approve {
count++
}
if requestChanges {
count++
}
if commentReview {
count++
}
if count != 1 {
return fmt.Errorf("exactly one of --approve, --request-changes, or --comment must be specified")
}
body, err := readBody(cmd)
if err != nil {
return err
}
if requestChanges && body == "" {
return fmt.Errorf("body is required when requesting changes (use --body or --body-file)")
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
var state gitea.ReviewStateType
var action string
switch {
case approve:
state = gitea.ReviewStateApproved
action = "approved"
case requestChanges:
state = gitea.ReviewStateRequestChanges
action = "reviewed with requested changes"
case commentReview:
state = gitea.ReviewStateComment
action = "reviewed with comment"
}
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
State: state,
Body: body,
})
if err != nil {
return fmt.Errorf("failed to create review: %w", err)
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(review)
}
fmt.Printf("PR #%d %s\n", prNumber, action)
if review.HTMLURL != "" {
fmt.Printf("View at: %s\n", review.HTMLURL)
}
return nil
}

View file

@ -11,13 +11,20 @@ import (
)
var cfgFile string
var jsonErrors bool
var rootCmd = &cobra.Command{
Use: "fgj",
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
Version: "0.2.0",
Version: "0.2.0",
SilenceErrors: true,
}
// JSONErrors reports whether the --json-errors flag is set.
func JSONErrors() bool {
return jsonErrors
}
func Execute() error {
@ -28,6 +35,7 @@ func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
}