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.
229 lines
5.8 KiB
Go
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
|
|
}
|