Commit 403424e7791b

Vincent Demeester <vincent@sbr.pm>
2025-12-16 11:13:09
feat(gh-pr): Add approve subcommand for batch PR approval
- Consolidate gh-approve functionality into unified gh-pr CLI - Enable flexible comment input (inline, editor, interactive) - Support Prow workflows with /lgtm integration Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 5e415cc
Changed files (3)
tools/gh-pr/cmd/gh-pr/approve.go
@@ -0,0 +1,374 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/spf13/cobra"
+	"github.com/vdemeester/home/tools/gh-pr/internal/output"
+)
+
+func approveCmd(out *output.Writer) *cobra.Command {
+	var (
+		prow          bool
+		merge         bool
+		force         bool
+		comment       string
+		editorComment bool
+		interactive   bool
+	)
+
+	cmd := &cobra.Command{
+		Use:   "approve [projects...]",
+		Short: "Approve pull requests",
+		Long: `Select and approve pull requests using fzf.
+
+This command lists pull requests from specified GitHub repositories and allows
+you to select and approve them interactively. You can optionally merge approved
+PRs and add custom comments.
+
+If no projects are specified, uses default Tekton projects:
+- tektoncd/pipeline
+- tektoncd/plumbing
+- tektoncd/cli
+- tektoncd/mcp-server
+
+Examples:
+  # Approve PRs from default projects
+  gh-pr approve
+
+  # Approve PRs from specific projects
+  gh-pr approve tektoncd/pipeline tektoncd/cli
+
+  # Approve with Prow comment
+  gh-pr approve -p tektoncd/pipeline
+
+  # Approve and merge
+  gh-pr approve -m tektoncd/pipeline
+
+  # Approve with custom comment
+  gh-pr approve -c "LGTM! Great work" tektoncd/pipeline
+
+  # Approve with editor for multi-line comment
+  gh-pr approve -C tektoncd/pipeline
+
+  # Force merge (requires admin)
+  gh-pr approve -m -f tektoncd/pipeline`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			projects := args
+			if len(projects) == 0 {
+				// Use default Tekton projects
+				projects = []string{
+					"tektoncd/pipeline",
+					"tektoncd/plumbing",
+					"tektoncd/cli",
+					"tektoncd/mcp-server",
+				}
+			}
+
+			return runApprove(out, approveOpts{
+				projects:      projects,
+				prow:          prow,
+				merge:         merge,
+				force:         force,
+				comment:       comment,
+				editorComment: editorComment,
+				interactive:   interactive,
+			})
+		},
+	}
+
+	cmd.Flags().BoolVarP(&prow, "prow", "p", false, "Add Prow /lgtm comment")
+	cmd.Flags().BoolVarP(&merge, "merge", "m", false, "Merge PR after approval")
+	cmd.Flags().BoolVarP(&force, "force", "f", false, "Force merge with --admin (requires admin rights)")
+	cmd.Flags().StringVarP(&comment, "comment", "c", "", "Custom approval comment")
+	cmd.Flags().BoolVarP(&editorComment, "editor", "C", false, "Open editor for multi-line comment")
+	cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Prompt for comment interactively")
+
+	return cmd
+}
+
+type approveOpts struct {
+	projects      []string
+	prow          bool
+	merge         bool
+	force         bool
+	comment       string
+	editorComment bool
+	interactive   bool
+}
+
+func runApprove(out *output.Writer, opts approveOpts) error {
+	// Check if fzf is available
+	if _, err := exec.LookPath("fzf"); err != nil {
+		return fmt.Errorf("fzf is required but not found in PATH: %w", err)
+	}
+
+	// Handle comment options
+	commentBody := opts.comment
+	if opts.editorComment {
+		body, err := getEditorComment()
+		if err != nil {
+			return fmt.Errorf("failed to get editor comment: %w", err)
+		}
+		commentBody = body
+	} else if commentBody == "" && opts.interactive {
+		out.Info("Enter approval comment (press Enter to skip):")
+		reader := bufio.NewReader(os.Stdin)
+		line, err := reader.ReadString('\n')
+		if err != nil {
+			return fmt.Errorf("failed to read comment: %w", err)
+		}
+		commentBody = strings.TrimSpace(line)
+	}
+
+	// Process each project
+	for _, project := range opts.projects {
+		out.Info("Processing project: %s", project)
+
+		if err := processProject(out, project, commentBody, opts); err != nil {
+			out.Error("Failed to process project %s: %v", project, err)
+			continue
+		}
+	}
+
+	return nil
+}
+
+func processProject(out *output.Writer, project, commentBody string, opts approveOpts) error {
+	// Build gh pr list command
+	ghArgs := []string{"pr", "list", "--repo", project, "--json", "number,title,author"}
+
+	ghCmd := exec.Command("gh", ghArgs...)
+	ghOutput, err := ghCmd.Output()
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			return fmt.Errorf("gh pr list failed: %s", exitErr.Stderr)
+		}
+		return fmt.Errorf("gh pr list failed: %w", err)
+	}
+
+	prListJSON := strings.TrimSpace(string(ghOutput))
+	if prListJSON == "" || prListJSON == "[]" {
+		out.Warning("No pull requests found in %s", project)
+		return nil
+	}
+
+	// Format PR list for fzf using jq
+	jqCmd := exec.Command("jq", "-r",
+		`.[] | (.number | tostring) + "\t" + .author.login + "\t" + .title`)
+	jqCmd.Stdin = strings.NewReader(prListJSON)
+	jqOutput, err := jqCmd.Output()
+	if err != nil {
+		return fmt.Errorf("failed to format PR list: %w", err)
+	}
+
+	formattedList := strings.TrimSpace(string(jqOutput))
+	if formattedList == "" {
+		out.Warning("No pull requests to display in %s", project)
+		return nil
+	}
+
+	// Use column to align the output
+	columnCmd := exec.Command("column", "-t", "-s", "\t")
+	columnCmd.Stdin = strings.NewReader(formattedList)
+	columnOutput, err := columnCmd.Output()
+	if err != nil {
+		// If column fails, use the original formatted list
+		columnOutput = []byte(formattedList)
+	}
+
+	// Build preview command for fzf
+	previewCmd := fmt.Sprintf(`gh pr checks --repo=%s {1} --json 'name,state' 2>/dev/null | \
+		jq -r 'map(.state + ": " + .name) | .[]' || echo "No checks found"`, project)
+
+	// Use fzf for multi-select with preview
+	out.Info("Select PRs to approve from %s (Tab: multi-select, Enter: confirm)...", project)
+	fzfCmd := exec.Command("fzf",
+		"--multi",
+		"--ansi",
+		"--header", fmt.Sprintf("Select PRs from %s (Tab: select, Enter: confirm)", project),
+		"--preview", previewCmd,
+		"--preview-window", "right:50%:wrap",
+	)
+	fzfCmd.Stdin = bytes.NewReader(columnOutput)
+	fzfCmd.Stderr = os.Stderr
+
+	selectedOutput, err := fzfCmd.Output()
+	if err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
+			// User cancelled with Ctrl+C
+			out.Info("Selection cancelled for %s", project)
+			return nil
+		}
+		return fmt.Errorf("fzf selection failed: %w", err)
+	}
+
+	selectedPRs := strings.TrimSpace(string(selectedOutput))
+	if selectedPRs == "" {
+		out.Info("No pull requests selected from %s", project)
+		return nil
+	}
+
+	// Extract PR numbers from selected lines
+	prNumbers := []string{}
+	scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
+	for scanner.Scan() {
+		line := scanner.Text()
+		// Extract PR number from the first field
+		fields := strings.Fields(line)
+		if len(fields) > 0 {
+			prNumbers = append(prNumbers, fields[0])
+		}
+	}
+
+	if len(prNumbers) == 0 {
+		out.Warning("No valid PR numbers found in selection")
+		return nil
+	}
+
+	out.Success("Selected %d pull request(s): %s", len(prNumbers), strings.Join(prNumbers, ", "))
+
+	// Approve each PR
+	failed := []string{}
+	for _, prNum := range prNumbers {
+		if err := approvePR(out, project, prNum, commentBody, opts); err != nil {
+			out.Error("Failed to approve PR #%s: %v", prNum, err)
+			failed = append(failed, prNum)
+			continue
+		}
+
+		// Merge if requested
+		if opts.merge {
+			if err := mergePR(out, project, prNum, commentBody, opts.force); err != nil {
+				out.Error("Failed to merge PR #%s: %v", prNum, err)
+				failed = append(failed, prNum)
+			}
+		}
+	}
+
+	// Summary
+	successCount := len(prNumbers) - len(failed)
+	if successCount > 0 {
+		out.Success("\nSuccessfully processed %d pull request(s) from %s", successCount, project)
+	}
+	if len(failed) > 0 {
+		out.Error("Failed to process %d pull request(s) from %s: %s",
+			len(failed), project, strings.Join(failed, ", "))
+	}
+
+	return nil
+}
+
+func approvePR(out *output.Writer, repo, prNum, commentBody string, opts approveOpts) error {
+	// Build review body
+	reviewBody := commentBody
+	if opts.prow {
+		if reviewBody != "" {
+			reviewBody = "/lgtm\n\n" + reviewBody
+		} else {
+			reviewBody = "/lgtm"
+		}
+	}
+
+	// Build gh pr review command
+	reviewArgs := []string{"pr", "review", "--repo", repo, prNum, "--approve"}
+	if reviewBody != "" {
+		reviewArgs = append(reviewArgs, "--body", reviewBody)
+	}
+
+	reviewCmd := exec.Command("gh", reviewArgs...)
+	if output, err := reviewCmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("gh pr review failed: %s", output)
+	}
+
+	out.Success("✓ Approved PR #%s", prNum)
+	return nil
+}
+
+func mergePR(out *output.Writer, repo, prNum, commentBody string, force bool) error {
+	// Build merge args
+	mergeArgs := []string{"pr", "merge", "--repo", repo}
+
+	if force {
+		mergeArgs = append(mergeArgs, "--admin")
+		out.Info("Merging PR #%s with admin privileges...", prNum)
+	} else {
+		mergeArgs = append(mergeArgs, "--auto")
+	}
+
+	mergeArgs = append(mergeArgs, "--rebase", "--delete-branch")
+
+	if commentBody != "" {
+		mergeArgs = append(mergeArgs, "--body", commentBody)
+	}
+
+	mergeArgs = append(mergeArgs, prNum)
+
+	// Execute merge
+	mergeCmd := exec.Command("gh", mergeArgs...)
+	if output, err := mergeCmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("gh pr merge failed: %s", output)
+	}
+
+	out.Success("✓ Merged PR #%s", prNum)
+	return nil
+}
+
+func getEditorComment() (string, error) {
+	// Create temporary file
+	tmpfile, err := os.CreateTemp("", "gh-pr-comment-*.md")
+	if err != nil {
+		return "", fmt.Errorf("failed to create temp file: %w", err)
+	}
+	defer os.Remove(tmpfile.Name())
+
+	// Write template
+	template := `# Enter your approval comment below
+# Lines starting with # will be ignored
+# Save and close the editor to continue
+
+`
+	if _, err := tmpfile.WriteString(template); err != nil {
+		return "", fmt.Errorf("failed to write template: %w", err)
+	}
+	tmpfile.Close()
+
+	// Get editor
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "vim"
+	}
+
+	// Open editor
+	editorCmd := exec.Command(editor, tmpfile.Name())
+	editorCmd.Stdin = os.Stdin
+	editorCmd.Stdout = os.Stdout
+	editorCmd.Stderr = os.Stderr
+
+	if err := editorCmd.Run(); err != nil {
+		return "", fmt.Errorf("editor failed: %w", err)
+	}
+
+	// Read back the file
+	content, err := os.ReadFile(tmpfile.Name())
+	if err != nil {
+		return "", fmt.Errorf("failed to read comment file: %w", err)
+	}
+
+	// Filter out comment lines and empty lines
+	var lines []string
+	scanner := bufio.NewScanner(bytes.NewReader(content))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if !strings.HasPrefix(line, "#") && strings.TrimSpace(line) != "" {
+			lines = append(lines, line)
+		}
+	}
+
+	return strings.Join(lines, "\n"), nil
+}
tools/gh-pr/cmd/gh-pr/main.go
@@ -37,6 +37,7 @@ and conflict resolution in a single command-line interface.`,
 	cmd.AddCommand(restartFailedCmd(out))
 	cmd.AddCommand(resolveConflictsCmd(out))
 	cmd.AddCommand(commentCmd(out))
+	cmd.AddCommand(approveCmd(out))
 
 	return cmd
 }
tools/gh-pr/README.md
@@ -120,6 +120,98 @@ gh-pr comment --author username
 - Add acknowledgments to a batch of PRs
 - Communicate breaking changes to affected PRs
 
+### `gh-pr approve`
+
+Approve and optionally merge pull requests interactively.
+
+```bash
+# Approve PRs from default projects (Tekton)
+gh-pr approve
+
+# Approve PRs from specific projects
+gh-pr approve tektoncd/pipeline tektoncd/cli
+
+# Approve with Prow comment (/lgtm)
+gh-pr approve -p tektoncd/pipeline
+
+# Approve and merge
+gh-pr approve -m tektoncd/pipeline
+
+# Approve with custom comment
+gh-pr approve -c "LGTM! Great work" tektoncd/pipeline
+
+# Approve with editor for multi-line comment
+gh-pr approve -C tektoncd/pipeline
+
+# Force merge (requires admin rights)
+gh-pr approve -m -f tektoncd/pipeline
+
+# Interactive comment prompt
+gh-pr approve -i tektoncd/pipeline
+```
+
+**Options:**
+- `-p, --prow`: Add Prow `/lgtm` comment (for Prow-based repos)
+- `-m, --merge`: Merge PR after approval (uses `--rebase` and `--delete-branch`)
+- `-f, --force`: Force merge with `--admin` flag (requires admin rights)
+- `-c, --comment`: Custom approval comment
+- `-C, --editor`: Open editor for multi-line comment
+- `-i, --interactive`: Prompt for comment interactively
+
+**Positional Arguments:**
+- `projects`: GitHub repositories in `owner/repo` format (optional)
+  - If not provided, uses default Tekton projects:
+    - `tektoncd/pipeline`
+    - `tektoncd/plumbing`
+    - `tektoncd/cli`
+    - `tektoncd/mcp-server`
+
+**How It Works:**
+
+1. **List PRs**: Fetches pull requests from each specified project
+2. **Select**: Uses fzf for multi-select with preview pane showing:
+   - PR number, author, and title
+   - CI check status (Tab to select, Enter to confirm)
+3. **Approve**: Approves all selected PRs with optional comment
+4. **Merge** (optional): If `-m` is set, merges approved PRs
+   - Uses `--rebase` to maintain linear history
+   - Uses `--delete-branch` to clean up after merge
+   - Uses `--admin` if `-f` is set (force merge)
+
+**Comment Handling:**
+
+The tool provides three ways to add approval comments:
+
+1. **Inline comment** (`-c`): Quick one-line comment
+   ```bash
+   gh-pr approve -c "LGTM! Nice work" tektoncd/pipeline
+   ```
+
+2. **Editor comment** (`-C`): Multi-line comment using your `$EDITOR`
+   ```bash
+   gh-pr approve -C tektoncd/pipeline
+   ```
+
+3. **Interactive prompt** (`-i`): Prompt for comment at runtime
+   ```bash
+   gh-pr approve -i tektoncd/pipeline
+   ```
+
+**Prow Integration:**
+
+For repositories using Prow (like Tekton projects), use the `-p` flag to automatically add the `/lgtm` command:
+
+```bash
+# This will approve with comment: "/lgtm\n\nLooks good!"
+gh-pr approve -p -c "Looks good!" tektoncd/pipeline
+```
+
+**Use Cases:**
+- Batch approve multiple related PRs
+- Approve and merge PRs in one workflow
+- Add consistent comments across multiple PRs
+- Work with multiple projects efficiently
+
 ### `gh-pr list-templates`
 
 List all available PR templates in the current or a remote repository.
@@ -351,6 +443,7 @@ gh-pr restart-failed --ignore "e2e-tests"
 
 This tool consolidates and replaces:
 
+- `gh-approve`: Now integrated as `gh-pr approve`
 - `gh-restart-failed`: Now integrated as `gh-pr restart-failed`
 - `gh-resolve-conflicts`: Now integrated as `gh-pr resolve-conflicts`