fj/cmd/pr_review.go
sid 43e43e7024 feat: v0.3.0a — add api command, pr diff/comment/review, structured errors
New commands:
- fgj api: raw REST API passthrough with field inference and path interpolation
- fgj pr diff: view PR diffs with color, --name-only, --stat
- fgj pr comment: add comments to pull requests
- fgj pr review: approve, request changes, or comment on PRs

Agentic enhancements:
- --json-errors flag for structured JSON error output on stderr
- APIError type wrapping HTTP status codes for machine consumption
- Error codes: auth_required, not_found, api_error, invalid_input, etc.

Docs updated for forgejo.zerova.net/sid/fgj-sid fork.
2026-03-21 21:50:24 -06:00

229 lines
5.8 KiB
Go

package cmd
import (
"fmt"
"io"
"os"
"strconv"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/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
}