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
33
CHANGELOG.md
33
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 <endpoint>` - 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 <number>` - 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 <number>` - Add a comment to a pull request
|
||||
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
||||
- JSON output (`--json`)
|
||||
- `fgj pr review <number>` - 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
|
||||
|
|
|
|||
101
README.md
101
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 <<EOF
|
|||
|
||||
Generated with AI assistance
|
||||
EOF
|
||||
)"
|
||||
)" --json
|
||||
|
||||
# Check PR status during development
|
||||
fgj pr list -R owner/repo --state open
|
||||
fgj pr list -R owner/repo --state open --json
|
||||
|
||||
# View PR details for review
|
||||
fgj pr view 123 -R owner/repo
|
||||
# Review a PR diff, then approve
|
||||
fgj pr diff 123
|
||||
fgj pr review 123 --approve -b "LGTM" --json
|
||||
|
||||
# Post review feedback
|
||||
fgj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
||||
|
||||
# Request changes with detailed feedback
|
||||
fgj pr review 123 --request-changes --body-file feedback.md --json
|
||||
|
||||
# Use raw API for anything not covered by commands
|
||||
fgj api /repos/{owner}/{repo}/topics --json-errors
|
||||
fgj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||
|
||||
# Fully machine-readable error handling
|
||||
fgj pr view 9999 --json --json-errors 2>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`
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
76
cmd/errors.go
Normal file
76
cmd/errors.go
Normal 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
271
cmd/pr_diff.go
Normal 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
229
cmd/pr_review.go
Normal 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
|
||||
}
|
||||
10
cmd/root.go
10
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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
17
internal/api/errors.go
Normal file
17
internal/api/errors.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
6
main.go
6
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue