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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue