From 50191cc542eb1556b3a8ab558e08951e3a18a426 Mon Sep 17 00:00:00 2001 From: sid Date: Sat, 21 Mar 2026 21:50:24 -0600 Subject: [PATCH] feat: add PR diff, PR review, and structured error handling commands --- CHANGELOG.md | 33 +++++ README.md | 101 +++++++++++++-- cmd/api.go | 260 +++++++++++++++++++++++++++++++++++++++ cmd/errors.go | 76 ++++++++++++ cmd/pr_diff.go | 271 +++++++++++++++++++++++++++++++++++++++++ cmd/pr_review.go | 229 ++++++++++++++++++++++++++++++++++ cmd/root.go | 10 +- internal/api/client.go | 18 ++- internal/api/errors.go | 17 +++ main.go | 6 +- 10 files changed, 1008 insertions(+), 13 deletions(-) create mode 100644 cmd/api.go create mode 100644 cmd/errors.go create mode 100644 cmd/pr_diff.go create mode 100644 cmd/pr_review.go create mode 100644 internal/api/errors.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 904eac2..d9f7be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### Raw API Access +- `fgj api ` - Make authenticated REST API requests to any Forgejo/Gitea endpoint + - HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided + - JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string) + - Raw string fields (`--raw-field`/`-F`) + - Request body from file or stdin (`--input`) + - Custom headers (`--header`/`-H`) + - Path interpolation (`{owner}`, `{repo}`) from git context + - Response header display (`--include`/`-i`) + +#### Pull Request Management +- `fgj pr diff ` - View the diff for a pull request + - Colorized output (`--color auto/always/never`) + - Changed file names only (`--name-only`) + - Diffstat summary (`--stat`) +- `fgj pr comment ` - Add a comment to a pull request + - Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin) + - JSON output (`--json`) +- `fgj pr review ` - Submit a review on a pull request + - Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`) + - Body from flag or file + - JSON output (`--json`) + +#### Agentic / Machine-Readable Output +- `--json-errors` global flag for structured JSON error output on stderr + - Error codes: `auth_required`, `not_found`, `api_error`, `invalid_input`, `git_detection_failed`, `network_error` + - HTTP status code and detail included when available + - Automatic mapping of API errors (401/403 → `auth_required`, 404 → `not_found`) + ## [0.3.0] - 2026-03-13 ### Added diff --git a/README.md b/README.md index d8cbe5f..8ec539d 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,15 @@ ## Features - Multi-instance support (works with any Forgejo instance) -- Pull request management (create, list, view, merge) +- Pull request management (create, list, view, merge, diff, comment, review) - Issue tracking (create, list, view, comment, close, labels) - Repository operations (view, list, create, clone, fork) - Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables) - Releases (create, upload, delete) +- Raw API access (`fgj api`) for arbitrary REST calls - Shell completions (bash, zsh, fish, PowerShell) and man pages - JSON output (`--json`) for all list/view commands +- Structured JSON error output (`--json-errors`) for machine consumption - Automatic repository and hostname detection from git context - Secure authentication with personal access tokens - XDG Base Directory compliant config location @@ -125,6 +127,33 @@ fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main # Merge a pull request fgj pr merge 123 --merge-method squash + +# View PR diff +fgj pr diff 123 + +# View diff with color +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 + +# Comment on a pull request +fgj pr comment 123 -b "Looks good, minor nit on line 42" + +# Comment from a file +fgj pr comment 123 --body-file review-notes.md + +# Approve a pull request +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 (neither approve nor request changes) +fgj pr review 123 --comment -b "Some observations" ``` ### Issues @@ -275,6 +304,31 @@ fgj actions variable update MY_VAR "new value" fgj actions variable delete MY_VAR ``` +### Raw API Access + +```bash +# GET request (auto-detects owner/repo from git context) +fgj api /repos/{owner}/{repo}/pulls + +# POST with fields +fgj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description" + +# Explicit method and hostname +fgj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com + +# Read request body from file +fgj api /repos/{owner}/{repo}/issues -X POST --input issue.json + +# Read from stdin +echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues -X POST --input - + +# Include response headers +fgj api /repos/{owner}/{repo} -i + +# Suppress output (useful for DELETE) +fgj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent +``` + ## Shell Completions and Man Pages ```bash @@ -297,6 +351,23 @@ fgj issue view 456 --json fgj release list --json fgj actions run list --json fgj actions workflow view ci.yml --json + +# Get JSON output from PR comment/review +fgj pr comment 123 -b "LGTM" --json +fgj pr review 123 --approve -b "Ship it" --json +``` + +### Structured Error Output + +For machine consumption (ideal for AI agents and scripts), use `--json-errors` to get structured JSON errors on stderr: + +```bash +# Errors are written to stderr as JSON +fgj pr view 9999 --json-errors +# stderr: {"error":{"code":"not_found","message":"...","status":404}} + +# Combine with --json for fully machine-readable I/O +fgj pr list --json --json-errors ``` ## Configuration @@ -337,7 +408,7 @@ When working in a git repository, `fgj` automatically detects the Forgejo instan ## Use with AI Coding Agents -`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns: +`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O: ```bash # Create PR from agent's changes @@ -348,13 +419,27 @@ fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <errors.json ``` ## Supported Forgejo Instances @@ -376,8 +461,8 @@ Contributions are welcome! Please feel free to submit a Pull Request. **Not Yet Implemented:** - `run delete` - Delete a workflow run - `run download` - Download workflow run artifacts -- `pr checkout`, `pr close/reopen`, `pr comment`, `pr diff` -- `pr review`, `pr checks`, `pr ready/draft` +- `pr checkout`, `pr close/reopen` +- `pr checks`, `pr ready/draft` - `issue reopen`, `issue assign` - `release edit`, `release download`, `release generate-notes` - `repo delete`, `repo rename`, `repo visibility` diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000..f28b759 --- /dev/null +++ b/cmd/api.go @@ -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 [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 + } +} diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..8e0f7bc --- /dev/null +++ b/cmd/errors.go @@ -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) +} diff --git a/cmd/pr_diff.go b/cmd/pr_diff.go new file mode 100644 index 0000000..f129c54 --- /dev/null +++ b/cmd/pr_diff.go @@ -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 ", + 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 +} diff --git a/cmd/pr_review.go b/cmd/pr_review.go new file mode 100644 index 0000000..5c77d88 --- /dev/null +++ b/cmd/pr_review.go @@ -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 ", + 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 ", + 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 +} diff --git a/cmd/root.go b/cmd/root.go index 6f5afa4..a22afb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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")) } diff --git a/internal/api/client.go b/internal/api/client.go index 083499c..e49c2f6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -76,7 +76,11 @@ func (c *Client) GetJSON(path string, result any) error { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return &APIError{ + StatusCode: resp.StatusCode, + Body: string(body), + Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(body)), + } } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { @@ -134,7 +138,11 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) - return resp.StatusCode, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return resp.StatusCode, &APIError{ + StatusCode: resp.StatusCode, + Body: string(bodyBytes), + Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)), + } } if result != nil && resp.StatusCode != http.StatusNoContent { @@ -171,7 +179,11 @@ func (c *Client) GetRawLog(url string) (string, error) { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return "", &APIError{ + StatusCode: resp.StatusCode, + Body: string(body), + Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, string(body)), + } } bodyBytes, err := io.ReadAll(resp.Body) diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..90b5e3d --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,17 @@ +package api + +import "fmt" + +// APIError represents a non-2xx HTTP response from the Forgejo API. +type APIError struct { + StatusCode int + Body string + Message string +} + +func (e *APIError) Error() string { + if e.Body != "" { + return fmt.Sprintf("API request failed with status %d: %s", e.StatusCode, e.Body) + } + return fmt.Sprintf("API request failed with status %d: %s", e.StatusCode, e.Message) +} diff --git a/main.go b/main.go index 4e596a3..770933d 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,11 @@ import ( func main() { if err := cmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + if cmd.JSONErrors() { + cmd.WriteJSONError(err) + } else { + fmt.Fprintln(os.Stderr, err) + } os.Exit(1) } }