Commit a2aed2947d90
Changed files (23)
pkgs
tools
cliphist-cleanup
gh-pr
cmd
internal
pkgs/cliphist-cleanup/default.nix
@@ -0,0 +1,30 @@
+{
+ lib,
+ buildGoModule,
+ fetchFromGitHub,
+}:
+
+buildGoModule rec {
+ pname = "cliphist-cleanup";
+ version = "0.1.0";
+
+ src = fetchFromGitHub {
+ owner = "vdemeester";
+ repo = "x";
+ rev = "be677194b497b7610395f9f269418224a9ad999d";
+ hash = "sha256-AfmqSrI7XicmM+tiAvB6bcAR4HHbQmxqeTTEyEXgdng=";
+ };
+
+ vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
+
+ # Build only the cliphist-cleanup command
+ subPackages = [ "cmd/cliphist-cleanup" ];
+
+ meta = {
+ description = "Clean up cliphist clipboard history by pattern matching";
+ homepage = "https://github.com/vdemeester/x";
+ license = lib.licenses.mit;
+ platforms = lib.platforms.linux;
+ mainProgram = "cliphist-cleanup";
+ };
+}
tools/gh-pr/default.nix → pkgs/gh-pr/default.nix
@@ -6,21 +6,28 @@
gh,
fzf,
jq,
+ fetchFromGitHub,
}:
-buildGoModule {
+buildGoModule rec {
pname = "gh-pr";
version = "0.1.0";
- src = ./.;
- vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k="; # No external dependencies
+ src = fetchFromGitHub {
+ owner = "vdemeester";
+ repo = "x";
+ rev = "be677194b497b7610395f9f269418224a9ad999d";
+ hash = "sha256-AfmqSrI7XicmM+tiAvB6bcAR4HHbQmxqeTTEyEXgdng=";
+ };
+
+ vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
nativeBuildInputs = [
makeWrapper
installShellFiles
];
- # Build all binaries
+ # Build the gh-pr command
subPackages = [ "cmd/gh-pr" ];
# Wrap binary to include gh, fzf, and jq in PATH and install completions
@@ -42,7 +49,8 @@ buildGoModule {
'';
meta = {
- description = "GitHub Pull Request management tool with template support, workflow restart, conflict resolution, and batch commenting";
+ description = "GitHub Pull Request management tool";
+ homepage = "https://github.com/vdemeester/x";
license = lib.licenses.mit;
platforms = lib.platforms.unix;
mainProgram = "gh-pr";
pkgs/default.nix
@@ -22,10 +22,10 @@ in
govanityurl = pkgs.callPackage ./govanityurl { };
batzconverter = pkgs.callPackage ./batzconverter { };
manifest-tool = pkgs.callPackage ./manifest-tool { };
- gh-pr = pkgs.callPackage ../tools/gh-pr { };
+ gh-pr = pkgs.callPackage ./gh-pr { };
arr = pkgs.callPackage ../tools/arr { };
download-kiwix-zim = pkgs.callPackage ../tools/download-kiwix-zim { };
- cliphist-cleanup = pkgs.callPackage ../tools/cliphist-cleanup { };
+ cliphist-cleanup = pkgs.callPackage ./cliphist-cleanup { };
toggle-color-scheme = pkgs.callPackage ./toggle-color-scheme { };
homepage = pkgs.callPackage ./homepage { inherit globals; };
audible-converter = pkgs.callPackage ./audible-converter { };
tools/cliphist-cleanup/default.nix
@@ -1,19 +0,0 @@
-{
- lib,
- buildGoModule,
-}:
-
-buildGoModule {
- pname = "cliphist-cleanup";
- version = "0.1.0";
- src = ./.;
-
- vendorHash = null;
-
- meta = {
- description = "Clean up cliphist clipboard history by pattern matching";
- license = lib.licenses.mit;
- platforms = lib.platforms.linux;
- mainProgram = "cliphist-cleanup";
- };
-}
tools/cliphist-cleanup/go.mod
@@ -1,3 +0,0 @@
-module cliphist-cleanup
-
-go 1.25.4
tools/cliphist-cleanup/main.go
@@ -1,108 +0,0 @@
-package main
-
-import (
- "bufio"
- "fmt"
- "os"
- "os/exec"
- "regexp"
- "strings"
-)
-
-func main() {
- if len(os.Args) < 2 {
- fmt.Fprintln(os.Stderr, "Usage: cliphist-cleanup <pattern> [pattern2] [pattern3] ...")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Examples:")
- fmt.Fprintln(os.Stderr, " cliphist-cleanup 'Signed-off-by:'")
- fmt.Fprintln(os.Stderr, " cliphist-cleanup '^# This is' 'Co-Authored-By:'")
- fmt.Fprintln(os.Stderr, "")
- fmt.Fprintln(os.Stderr, "Patterns are treated as regular expressions (case-insensitive).")
- os.Exit(1)
- }
-
- // Compile patterns
- patterns := make([]*regexp.Regexp, 0, len(os.Args)-1)
- for _, pattern := range os.Args[1:] {
- re, err := regexp.Compile("(?i)" + pattern)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error compiling pattern '%s': %v\n", pattern, err)
- os.Exit(1)
- }
- patterns = append(patterns, re)
- }
-
- // Get clipboard history
- cmd := exec.Command("cliphist", "list")
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error creating pipe: %v\n", err)
- os.Exit(1)
- }
-
- if err := cmd.Start(); err != nil {
- fmt.Fprintf(os.Stderr, "Error starting cliphist list: %v\n", err)
- os.Exit(1)
- }
-
- // Read entries and delete matching ones
- scanner := bufio.NewScanner(stdout)
- deleted := 0
- checked := 0
-
- for scanner.Scan() {
- line := scanner.Text()
- checked++
-
- // Check if line matches any pattern
- matched := false
- for _, re := range patterns {
- if re.MatchString(line) {
- matched = true
- break
- }
- }
-
- if matched {
- // Delete this entry using delete-query
- // The line format from cliphist list is: "ID\tCONTENT"
- // We need to extract the content part after the tab
- parts := strings.SplitN(line, "\t", 2)
- if len(parts) < 2 {
- continue
- }
-
- content := parts[1]
- deleteCmd := exec.Command("cliphist", "delete-query", content)
- if err := deleteCmd.Run(); err != nil {
- fmt.Fprintf(os.Stderr, "Warning: Failed to delete entry: %v\n", err)
- } else {
- deleted++
- fmt.Printf("Deleted: %s\n", truncate(content, 80))
- }
- }
- }
-
- if err := scanner.Err(); err != nil {
- fmt.Fprintf(os.Stderr, "Error reading cliphist output: %v\n", err)
- os.Exit(1)
- }
-
- if err := cmd.Wait(); err != nil {
- fmt.Fprintf(os.Stderr, "Error waiting for cliphist: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("\nChecked %d entries, deleted %d entries\n", checked, deleted)
-}
-
-func truncate(s string, maxLen int) string {
- // Replace newlines with spaces for display
- s = strings.ReplaceAll(s, "\n", " ")
- s = strings.ReplaceAll(s, "\r", " ")
-
- if len(s) <= maxLen {
- return s
- }
- return s[:maxLen-3] + "..."
-}
tools/cliphist-cleanup/README.md
@@ -1,48 +0,0 @@
-# cliphist-cleanup
-
-Clean up your clipboard history by deleting entries matching specific patterns.
-
-## Usage
-
-```bash
-cliphist-cleanup <pattern> [pattern2] [pattern3] ...
-```
-
-Patterns are treated as case-insensitive regular expressions.
-
-## Examples
-
-Delete all git sign-off entries:
-```bash
-cliphist-cleanup 'Signed-off-by:'
-```
-
-Delete multiple types of git commit messages:
-```bash
-cliphist-cleanup '# This is a combination' 'Co-Authored-By:'
-```
-
-Delete entries starting with specific text:
-```bash
-cliphist-cleanup '^password:' '^token:'
-```
-
-## How It Works
-
-1. Lists all clipboard history entries using `cliphist list`
-2. Matches each entry against the provided patterns
-3. Deletes matching entries using `cliphist delete-query`
-4. Reports the number of entries checked and deleted
-
-## Building
-
-```bash
-nix build .#cliphist-cleanup
-```
-
-## Installing
-
-Add to your home-manager packages or install directly:
-```bash
-nix profile install .#cliphist-cleanup
-```
tools/gh-pr/cmd/gh-pr/approve.go
@@ -1,374 +0,0 @@
-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/cleanup.go
@@ -1,422 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func cleanupCmd(out *output.Writer) *cobra.Command {
- var (
- worktreeDir string
- dryRun bool
- checkMerged bool
- checkUpstream bool
- force bool
- upstreamBranch string
- )
-
- cmd := &cobra.Command{
- Use: "cleanup [DIRECTORY]",
- Short: "Clean up git worktrees without uncommitted changes",
- Long: `Remove git worktrees that have no uncommitted changes.
-
-This command scans for git repositories with worktrees and removes those that:
- - Have no uncommitted changes (clean working directory)
- - Optionally: are merged upstream (with --check-upstream)
- - Optionally: have merged/closed PRs (with --check-merged)
-
-By default, it scans the directory used by 'resolve-conflicts' command.
-
-Examples:
- # Clean up default worktree directory (dry run)
- gh-pr cleanup --dry-run
-
- # Clean up and remove worktrees
- gh-pr cleanup
-
- # Clean up custom directory
- gh-pr cleanup ~/my-worktrees
-
- # Check if commits are merged upstream before removing
- gh-pr cleanup --check-upstream
-
- # Check if PR is merged before removing
- gh-pr cleanup --check-merged
-
- # Check both upstream and PR status
- gh-pr cleanup --check-upstream --check-merged
-
- # Force remove even with uncommitted changes (dangerous!)
- gh-pr cleanup --force`,
- RunE: func(cmd *cobra.Command, args []string) error {
- // Override worktreeDir if provided as argument
- if len(args) > 0 {
- worktreeDir = args[0]
- }
-
- return runCleanup(out, cleanupOpts{
- worktreeDir: worktreeDir,
- dryRun: dryRun,
- checkMerged: checkMerged,
- checkUpstream: checkUpstream,
- force: force,
- upstreamBranch: upstreamBranch,
- })
- },
- }
-
- cmd.Flags().StringVarP(&worktreeDir, "worktree", "w", "/tmp/gh-resolve-conflicts-worktrees", "Directory containing worktrees")
- cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Show what would be removed without removing")
- cmd.Flags().BoolVarP(&checkMerged, "check-merged", "m", false, "Only remove worktrees for merged/closed PRs")
- cmd.Flags().BoolVarP(&checkUpstream, "check-upstream", "u", false, "Check if commits are merged upstream")
- cmd.Flags().StringVarP(&upstreamBranch, "upstream-branch", "b", "main", "Upstream branch to check against")
- cmd.Flags().BoolVarP(&force, "force", "f", false, "Force remove even with uncommitted changes")
-
- return cmd
-}
-
-type cleanupOpts struct {
- worktreeDir string
- dryRun bool
- checkMerged bool
- checkUpstream bool
- force bool
- upstreamBranch string
-}
-
-type mergeStatus struct {
- prMerged bool
- upstreamMerged bool
- prChecked bool
- upstreamChecked bool
-}
-
-type worktreeInfo struct {
- path string
- branch string
- prNum string
- repo string
-}
-
-func runCleanup(out *output.Writer, opts cleanupOpts) error {
- if _, err := os.Stat(opts.worktreeDir); os.IsNotExist(err) {
- out.Warning("Directory does not exist: %s", opts.worktreeDir)
- return nil
- }
-
- out.Info("Scanning for git worktrees in: %s", opts.worktreeDir)
- fmt.Println()
-
- // Find all directories that might contain git repos
- entries, err := os.ReadDir(opts.worktreeDir)
- if err != nil {
- return fmt.Errorf("failed to read directory: %w", err)
- }
-
- totalRemoved := 0
- totalKept := 0
-
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
-
- repoName := entry.Name()
- repoPath := filepath.Join(opts.worktreeDir, repoName)
-
- // Look for main directory (the base repo)
- mainPath := filepath.Join(repoPath, "main")
- if _, err := os.Stat(filepath.Join(mainPath, ".git")); os.IsNotExist(err) {
- continue
- }
-
- out.Info("📁 Repository: %s", repoName)
-
- // Get list of worktrees
- worktrees, err := getWorktrees(mainPath)
- if err != nil {
- out.Error("Failed to list worktrees: %v", err)
- continue
- }
-
- if len(worktrees) == 0 {
- out.Info(" No worktrees found")
- fmt.Println()
- continue
- }
-
- // Process each worktree
- for _, wt := range worktrees {
- wt.repo = repoName
- removed, kept, err := processWorktree(out, mainPath, wt, opts)
- if err != nil {
- out.Error(" Failed to process worktree %s: %v", wt.path, err)
- continue
- }
- totalRemoved += removed
- totalKept += kept
- }
-
- fmt.Println()
- }
-
- // Summary
- if opts.dryRun {
- out.Success("Dry run complete. Would remove %d worktrees, keeping %d", totalRemoved, totalKept)
- } else {
- out.Success("Cleanup complete! Removed %d worktrees, kept %d", totalRemoved, totalKept)
- }
-
- return nil
-}
-
-func getWorktrees(mainPath string) ([]worktreeInfo, error) {
- cmd := exec.Command("git", "-C", mainPath, "worktree", "list", "--porcelain")
- output, err := cmd.Output()
- if err != nil {
- return nil, err
- }
-
- var worktrees []worktreeInfo
- var current worktreeInfo
- prPattern := regexp.MustCompile(`^pr-(\d+)$`)
-
- for _, line := range strings.Split(string(output), "\n") {
- line = strings.TrimSpace(line)
- if line == "" {
- if current.path != "" && !strings.HasSuffix(current.path, "/main") {
- // Extract PR number from branch name if it matches pr-XXX
- if matches := prPattern.FindStringSubmatch(current.branch); len(matches) > 1 {
- current.prNum = matches[1]
- }
- worktrees = append(worktrees, current)
- }
- current = worktreeInfo{}
- continue
- }
-
- if strings.HasPrefix(line, "worktree ") {
- current.path = strings.TrimPrefix(line, "worktree ")
- } else if strings.HasPrefix(line, "branch ") {
- branch := strings.TrimPrefix(line, "branch ")
- // Remove refs/heads/ prefix
- current.branch = strings.TrimPrefix(branch, "refs/heads/")
- }
- }
-
- // Don't forget the last one
- if current.path != "" && !strings.HasSuffix(current.path, "/main") {
- if matches := prPattern.FindStringSubmatch(current.branch); len(matches) > 1 {
- current.prNum = matches[1]
- }
- worktrees = append(worktrees, current)
- }
-
- return worktrees, nil
-}
-
-func processWorktree(out *output.Writer, mainPath string, wt worktreeInfo, opts cleanupOpts) (removed int, kept int, err error) {
- name := filepath.Base(wt.path)
-
- // Check if worktree still exists
- if _, err := os.Stat(wt.path); os.IsNotExist(err) {
- out.Warning(" ⚠️ %s [%s] - path does not exist, pruning", name, wt.branch)
- if !opts.dryRun {
- cmd := exec.Command("git", "-C", mainPath, "worktree", "prune")
- if err := cmd.Run(); err != nil {
- return 0, 0, fmt.Errorf("failed to prune: %w", err)
- }
- }
- return 1, 0, nil
- }
-
- // Check for uncommitted changes
- hasChanges, err := hasUncommittedChanges(wt.path)
- if err != nil {
- return 0, 0, err
- }
-
- if hasChanges && !opts.force {
- out.Warning(" ⚠️ %s [%s] - has uncommitted changes (keeping)", name, wt.branch)
- return 0, 1, nil
- }
-
- // Check merge status
- status := mergeStatus{}
-
- // Check if PR is merged/closed (if requested)
- if opts.checkMerged && wt.prNum != "" {
- merged, err := isPRMergedOrClosed(wt.repo, wt.prNum)
- if err != nil {
- out.Warning(" ⚠️ %s [%s] - failed to check PR status: %v (keeping)", name, wt.branch, err)
- return 0, 1, nil
- }
- status.prMerged = merged
- status.prChecked = true
- }
-
- // Check if commits are merged upstream (if requested)
- if opts.checkUpstream {
- merged, err := isBranchMergedUpstream(mainPath, wt.branch, opts.upstreamBranch)
- if err != nil {
- // Don't fail, just note we couldn't check
- status.upstreamChecked = false
- } else {
- status.upstreamMerged = merged
- status.upstreamChecked = true
- }
- }
-
- // Decide whether to keep or remove based on status
- shouldRemove, reason := shouldRemoveWorktree(status, opts, wt)
-
- if !shouldRemove {
- printKeepStatus(out, name, wt.branch, wt.prNum, status, reason)
- return 0, 1, nil
- }
-
- // Remove the worktree
- printRemoveStatus(out, name, wt.branch, wt.prNum, status, opts.dryRun)
-
- if !opts.dryRun {
- cmd := exec.Command("git", "-C", mainPath, "worktree", "remove", wt.path)
- if err := cmd.Run(); err != nil {
- // Try with --force
- out.Warning(" Retrying with --force...")
- cmd = exec.Command("git", "-C", mainPath, "worktree", "remove", "--force", wt.path)
- if err := cmd.Run(); err != nil {
- return 0, 0, fmt.Errorf("failed to remove: %w", err)
- }
- }
- }
-
- return 1, 0, nil
-}
-
-func shouldRemoveWorktree(status mergeStatus, opts cleanupOpts, wt worktreeInfo) (bool, string) {
- // If we're checking merge status, only remove if something is merged
- if opts.checkMerged || opts.checkUpstream {
- if status.prChecked && status.prMerged {
- return true, "PR merged"
- }
- if status.upstreamChecked && status.upstreamMerged {
- return true, "commits merged upstream"
- }
- // Neither merged, keep it
- if status.prChecked && !status.prMerged {
- return false, "PR still open"
- }
- if status.upstreamChecked && !status.upstreamMerged {
- return false, "not merged upstream"
- }
- return false, "merge status unknown"
- }
-
- // If not checking merge status, remove all clean worktrees
- return true, "clean"
-}
-
-func printKeepStatus(out *output.Writer, name, branch, prNum string, status mergeStatus, reason string) {
- prLabel := ""
- if prNum != "" {
- prLabel = fmt.Sprintf(" PR #%s", prNum)
- }
-
- statusInfo := ""
- if status.upstreamChecked {
- if status.upstreamMerged {
- statusInfo = " [merged upstream]"
- } else {
- statusInfo = " [not merged upstream]"
- }
- }
- if status.prChecked {
- if status.prMerged {
- statusInfo += " [PR merged]"
- } else {
- statusInfo += " [PR open]"
- }
- }
-
- out.Info(" ℹ️ %s [%s]%s%s - %s (keeping)", name, branch, prLabel, statusInfo, reason)
-}
-
-func printRemoveStatus(out *output.Writer, name, branch, prNum string, status mergeStatus, dryRun bool) {
- prLabel := ""
- if prNum != "" {
- prLabel = fmt.Sprintf(" PR #%s", prNum)
- }
-
- action := "removing"
- if dryRun {
- action = "would remove"
- }
-
- // Use different emojis/colors based on merge status
- if status.upstreamMerged && status.upstreamChecked {
- // Merged upstream - use checkmark (green by default via Success)
- fmt.Printf(" ✅ %s [%s]%s - merged upstream (%s)\n", name, branch, prLabel, action)
- } else if status.prMerged && status.prChecked {
- // PR merged but maybe not upstream yet - use green circle
- fmt.Printf(" ✅ %s [%s]%s - PR merged (%s)\n", name, branch, prLabel, action)
- } else {
- // Clean but not verified as merged - use trash can (standard removal)
- fmt.Printf(" 🗑️ %s [%s]%s - clean (%s)\n", name, branch, prLabel, action)
- }
-}
-
-func hasUncommittedChanges(path string) (bool, error) {
- cmd := exec.Command("git", "-C", path, "status", "--porcelain")
- output, err := cmd.Output()
- if err != nil {
- return false, err
- }
- return len(strings.TrimSpace(string(output))) > 0, nil
-}
-
-func isPRMergedOrClosed(repo, prNum string) (bool, error) {
- // Use gh to check PR state
- cmd := exec.Command("gh", "pr", "view", prNum, "--repo", repo, "--json", "state", "--jq", ".state")
- output, err := cmd.Output()
- if err != nil {
- return false, err
- }
-
- state := strings.TrimSpace(string(output))
- return state == "MERGED" || state == "CLOSED", nil
-}
-
-func isBranchMergedUpstream(repoPath, branch, upstreamBranch string) (bool, error) {
- // First, check if the branch exists
- checkCmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", branch)
- if err := checkCmd.Run(); err != nil {
- // Branch doesn't exist, consider it merged/deleted
- return true, nil
- }
-
- // Check if there are commits in the branch that are not in upstream
- // git cherry returns commits that exist in branch but not in upstream
- // Empty output means all commits are merged
- cmd := exec.Command("git", "-C", repoPath, "cherry", upstreamBranch, branch)
- output, err := cmd.Output()
- if err != nil {
- // If cherry fails, try a different approach: check if branch is ancestor of upstream
- mergeBaseCmd := exec.Command("git", "-C", repoPath, "merge-base", "--is-ancestor", branch, upstreamBranch)
- if mergeBaseErr := mergeBaseCmd.Run(); mergeBaseErr == nil {
- // Branch is ancestor of upstream, so it's merged
- return true, nil
- }
- return false, err
- }
-
- // If output is empty, all commits from branch are in upstream
- result := strings.TrimSpace(string(output))
- return result == "", nil
-}
tools/gh-pr/cmd/gh-pr/comment.go
@@ -1,233 +0,0 @@
-package main
-
-import (
- "bufio"
- "bytes"
- "fmt"
- "os"
- "os/exec"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func commentCmd(out *output.Writer) *cobra.Command {
- var (
- body string
- repo string
- labels []string
- author string
- state string
- )
-
- cmd := &cobra.Command{
- Use: "comment",
- Short: "Comment on multiple pull requests",
- Long: `Select and comment on multiple pull requests using fzf.
-
-This command lists pull requests and allows you to select multiple PRs
-using fzf's multi-select feature. You can then add the same comment to
-all selected PRs.
-
-Examples:
- gh-pr comment # Select PRs interactively
- gh-pr comment --body "LGTM" # Pre-specify the comment
- gh-pr comment --label bug # Filter by label
- gh-pr comment --repo owner/repo # Work with a specific repo
- gh-pr comment --state all # Include closed PRs`,
- RunE: func(cmd *cobra.Command, args []string) error {
- return runComment(out, commentOpts{
- body: body,
- repo: repo,
- labels: labels,
- author: author,
- state: state,
- })
- },
- }
-
- cmd.Flags().StringVarP(&body, "body", "b", "", "Comment body (will prompt if not provided)")
- cmd.Flags().StringVarP(&repo, "repo", "R", "", "Repository (owner/repo format)")
- cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Filter PRs by label (comma-separated)")
- cmd.Flags().StringVarP(&author, "author", "a", "", "Filter PRs by author")
- cmd.Flags().StringVarP(&state, "state", "s", "open", "Filter by state: open, closed, merged, all")
-
- return cmd
-}
-
-type commentOpts struct {
- body string
- repo string
- labels []string
- author string
- state string
-}
-
-func runComment(out *output.Writer, opts commentOpts) 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)
- }
-
- // Build gh pr list command
- ghArgs := []string{"pr", "list"}
-
- if opts.repo != "" {
- ghArgs = append(ghArgs, "--repo", opts.repo)
- }
-
- for _, label := range opts.labels {
- ghArgs = append(ghArgs, "--label", label)
- }
-
- if opts.author != "" {
- ghArgs = append(ghArgs, "--author", opts.author)
- }
-
- if opts.state != "" {
- ghArgs = append(ghArgs, "--state", opts.state)
- }
-
- // Get list of PRs
- out.Info("Fetching pull requests...")
- 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)
- }
-
- prList := strings.TrimSpace(string(ghOutput))
- if prList == "" {
- out.Warning("No pull requests found matching the criteria.")
- return nil
- }
-
- // Build preview command for fzf
- repoFlag := ""
- if opts.repo != "" {
- repoFlag = fmt.Sprintf("-R %s", opts.repo)
- }
-
- previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
- jq -r '"# PR " + (.number | tostring) + ": " + .title,
- "",
- "Author: @" + .author.login,
- "",
- "## Status Checks:",
- "",
- (if (.statusCheckRollup // [] | length) == 0 then " (No status checks)"
- else (.statusCheckRollup | map(
- " " + (
- if .conclusion == "SUCCESS" then "✓"
- elif .conclusion == "FAILURE" then "✗"
- elif .conclusion == "PENDING" then "●"
- elif .conclusion == "SKIPPED" then "○"
- else "?"
- end
- ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
- ) | join("\n"))
- end)'`, repoFlag)
-
- // Use fzf for multi-select with preview
- out.Info("Select pull requests (use Tab to select multiple, Enter to confirm)...")
- fzfCmd := exec.Command("fzf",
- "--multi",
- "--ansi",
- "--header", "Select PRs (Tab: select, Enter: confirm)",
- "--preview", previewCmd,
- "--preview-window", "right:60%:wrap",
- )
- fzfCmd.Stdin = strings.NewReader(prList)
- 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.")
- return nil
- }
- return fmt.Errorf("fzf selection failed: %w", err)
- }
-
- selectedPRs := strings.TrimSpace(string(selectedOutput))
- if selectedPRs == "" {
- out.Info("No pull requests selected.")
- 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 (format: "123 title...")
- 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, ", "))
-
- // Get comment body if not provided
- commentBody := opts.body
- if commentBody == "" {
- out.Info("Enter your comment (Ctrl+D when done):")
- var buf bytes.Buffer
- scanner := bufio.NewScanner(os.Stdin)
- for scanner.Scan() {
- buf.WriteString(scanner.Text())
- buf.WriteString("\n")
- }
- if err := scanner.Err(); err != nil {
- return fmt.Errorf("failed to read comment: %w", err)
- }
- commentBody = strings.TrimSpace(buf.String())
- }
-
- if commentBody == "" {
- out.Warning("Empty comment body. No comments will be posted.")
- return nil
- }
-
- // Comment on each PR
- out.Info("Posting comment to %d pull request(s)...", len(prNumbers))
- failed := []string{}
-
- for _, prNum := range prNumbers {
- commentArgs := []string{"pr", "comment", prNum, "--body", commentBody}
- if opts.repo != "" {
- commentArgs = append(commentArgs, "--repo", opts.repo)
- }
-
- commentCmd := exec.Command("gh", commentArgs...)
- if err := commentCmd.Run(); err != nil {
- out.Error("Failed to comment on PR #%s: %v", prNum, err)
- failed = append(failed, prNum)
- } else {
- out.Success("Commented on PR #%s", prNum)
- }
- }
-
- // Summary
- successCount := len(prNumbers) - len(failed)
- if successCount > 0 {
- out.Success("\nSuccessfully commented on %d pull request(s)", successCount)
- }
- if len(failed) > 0 {
- out.Error("Failed to comment on %d pull request(s): %s", len(failed), strings.Join(failed, ", "))
- return fmt.Errorf("some comments failed")
- }
-
- return nil
-}
tools/gh-pr/cmd/gh-pr/create.go
@@ -1,182 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
- "github.com/vdemeester/home/tools/gh-pr/internal/templates"
-)
-
-func createCmd(out *output.Writer) *cobra.Command {
- var (
- title string
- body string
- template string
- draft bool
- base string
- head string
- web bool
- reviewers []string
- assignees []string
- labels []string
- refresh bool
- )
-
- cmd := &cobra.Command{
- Use: "create",
- Short: "Create a pull request",
- Long: `Create a pull request with optional template support.
-
-Templates are automatically discovered from:
- - .github/PULL_REQUEST_TEMPLATE.md
- - .github/PULL_REQUEST_TEMPLATE/
- - docs/PULL_REQUEST_TEMPLATE.md
-
-Use --template to specify a template file, or list available templates
-with 'gh-pr list-templates'.`,
- RunE: func(cmd *cobra.Command, args []string) error {
- return runCreate(out, createOpts{
- title: title,
- body: body,
- template: template,
- draft: draft,
- base: base,
- head: head,
- web: web,
- reviewers: reviewers,
- assignees: assignees,
- labels: labels,
- refresh: refresh,
- })
- },
- }
-
- cmd.Flags().StringVarP(&title, "title", "t", "", "Pull request title")
- cmd.Flags().StringVarP(&body, "body", "b", "", "Pull request body")
- cmd.Flags().StringVar(&template, "template", "", "Use a specific template file")
- cmd.Flags().BoolVarP(&draft, "draft", "d", false, "Create as draft pull request")
- cmd.Flags().StringVar(&base, "base", "", "Base branch (default: main/master)")
- cmd.Flags().StringVar(&head, "head", "", "Head branch (default: current branch)")
- cmd.Flags().BoolVarP(&web, "web", "w", false, "Open in web browser")
- cmd.Flags().StringSliceVarP(&reviewers, "reviewer", "r", nil, "Request reviewers (comma-separated)")
- cmd.Flags().StringSliceVarP(&assignees, "assignee", "a", nil, "Assign users (comma-separated)")
- cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Add labels (comma-separated)")
- cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh template cache")
-
- return cmd
-}
-
-type createOpts struct {
- title string
- body string
- template string
- draft bool
- base string
- head string
- web bool
- reviewers []string
- assignees []string
- labels []string
- refresh bool
-}
-
-func runCreate(out *output.Writer, opts createOpts) error {
- // If template is specified, load it
- if opts.template != "" {
- content, err := loadTemplate(out, opts.template, opts.refresh)
- if err != nil {
- return err
- }
-
- // Use template content if body is empty
- if opts.body == "" {
- opts.body = content
- }
- }
-
- // Build gh pr create command
- ghArgs := []string{"pr", "create"}
-
- if opts.title != "" {
- ghArgs = append(ghArgs, "--title", opts.title)
- }
-
- if opts.body != "" {
- ghArgs = append(ghArgs, "--body", opts.body)
- }
-
- if opts.draft {
- ghArgs = append(ghArgs, "--draft")
- }
-
- if opts.base != "" {
- ghArgs = append(ghArgs, "--base", opts.base)
- }
-
- if opts.head != "" {
- ghArgs = append(ghArgs, "--head", opts.head)
- }
-
- if opts.web {
- ghArgs = append(ghArgs, "--web")
- }
-
- for _, reviewer := range opts.reviewers {
- ghArgs = append(ghArgs, "--reviewer", reviewer)
- }
-
- for _, assignee := range opts.assignees {
- ghArgs = append(ghArgs, "--assignee", assignee)
- }
-
- for _, label := range opts.labels {
- ghArgs = append(ghArgs, "--label", label)
- }
-
- out.Info("Creating pull request...")
-
- // Execute gh command
- cmd := exec.Command("gh", ghArgs...)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.Stdin = os.Stdin
-
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("gh pr create failed: %w", err)
- }
-
- return nil
-}
-
-func loadTemplate(out *output.Writer, templatePath string, refresh bool) (string, error) {
- finder, err := templates.NewFinder()
- if err != nil {
- return "", err
- }
-
- // If template path is just a name, try to find it
- if !strings.Contains(templatePath, "/") {
- out.Info("Searching for template: %s", templatePath)
-
- tmplList, err := finder.Find(refresh)
- if err != nil {
- return "", fmt.Errorf("failed to find templates: %w", err)
- }
-
- for _, tmpl := range tmplList {
- if tmpl.Name == templatePath || tmpl.Path == templatePath {
- out.Success("Found template: %s", tmpl.Path)
- return tmpl.Content, nil
- }
- }
-
- return "", fmt.Errorf("template not found: %s", templatePath)
- }
-
- // Direct file path
- return templates.ReadTemplate(templatePath)
-}
tools/gh-pr/cmd/gh-pr/list_templates.go
@@ -1,148 +0,0 @@
-package main
-
-import (
- "fmt"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
- "github.com/vdemeester/home/tools/gh-pr/internal/templates"
-)
-
-func listTemplatesCmd(out *output.Writer) *cobra.Command {
- var (
- refresh bool
- verbose bool
- )
-
- cmd := &cobra.Command{
- Use: "list-templates [REPOSITORY]",
- Short: "List available pull request templates",
- Long: `List all pull request templates found in the repository.
-
-Templates are cached for one week by default. Use --refresh to bypass
-the cache and search for templates again.
-
-Examples:
- gh-pr list-templates # List templates in current repo
- gh-pr list-templates tektoncd/pipeline # List templates from remote repo
- gh-pr list-templates --verbose # Show template previews
- gh-pr list-templates --refresh # Bypass cache`,
- RunE: func(cmd *cobra.Command, args []string) error {
- var repo string
- if len(args) > 0 {
- repo = args[0]
- }
- return runListTemplates(out, repo, refresh, verbose)
- },
- }
-
- cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh template cache")
- cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show template content preview")
-
- return cmd
-}
-
-func runListTemplates(out *output.Writer, repo string, refresh, verbose bool) error {
- finder, err := templates.NewFinder()
- if err != nil {
- return fmt.Errorf("failed to create template finder: %w", err)
- }
-
- var tmplList []templates.Template
-
- if repo != "" {
- // Search in remote repository
- if refresh {
- out.Info("Fetching templates from %s (bypassing cache)...", repo)
- } else {
- out.Info("Searching for templates in %s...", repo)
- }
-
- tmplList, err = finder.FindInRepo(repo, refresh)
- if err != nil {
- return fmt.Errorf("failed to find templates in %s: %w", repo, err)
- }
- } else {
- // Search in current repository
- if refresh {
- out.Info("Refreshing template cache...")
- }
-
- tmplList, err = finder.Find(refresh)
- if err != nil {
- return fmt.Errorf("failed to find templates: %w", err)
- }
- }
-
- if len(tmplList) == 0 {
- if repo != "" {
- out.Warning("No pull request templates found in %s.", repo)
- } else {
- out.Warning("No pull request templates found.")
- }
- out.Println("")
- out.Println("Templates are typically located in:")
- out.Println(" - .github/PULL_REQUEST_TEMPLATE.md")
- out.Println(" - .github/PULL_REQUEST_TEMPLATE/")
- out.Println(" - docs/PULL_REQUEST_TEMPLATE.md")
- return nil
- }
-
- if repo != "" {
- out.Success("Found %d pull request template(s) in %s:", len(tmplList), repo)
- } else {
- out.Success("Found %d pull request template(s):", len(tmplList))
- }
- out.Println("")
-
- for i, tmpl := range tmplList {
- out.Println("%d. %s", i+1, tmpl.Name)
- out.Println(" Path: %s", tmpl.Path)
-
- if verbose {
- // Show first few lines of template
- lines := splitLines(tmpl.Content, 5)
- out.Println(" Preview:")
- for _, line := range lines {
- out.Println(" %s", line)
- }
- if len(lines) == 5 {
- out.Println(" ...")
- }
- }
-
- if i < len(tmplList)-1 {
- out.Println("")
- }
- }
-
- out.Println("")
- out.Info("Use 'gh-pr create --template <name>' to create a PR with a template")
-
- return nil
-}
-
-func splitLines(content string, max int) []string {
- lines := []string{}
- current := ""
-
- for i, char := range content {
- if char == '\n' {
- lines = append(lines, current)
- current = ""
-
- if len(lines) >= max {
- break
- }
- } else {
- current += string(char)
- }
-
- // Handle last line
- if i == len(content)-1 && current != "" {
- lines = append(lines, current)
- }
- }
-
- return lines
-}
tools/gh-pr/cmd/gh-pr/main.go
@@ -1,54 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-var version = "0.1.0"
-
-func main() {
- if err := rootCmd().Execute(); err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
-}
-
-func rootCmd() *cobra.Command {
- out := output.Default()
-
- cmd := &cobra.Command{
- Use: "gh-pr",
- Short: "GitHub Pull Request management tool",
- Long: `A comprehensive tool for managing GitHub pull requests.
-
-Combines PR creation with template support, workflow management,
-and conflict resolution in a single command-line interface.`,
- SilenceUsage: true,
- SilenceErrors: true,
- }
-
- cmd.AddCommand(versionCmd())
- cmd.AddCommand(createCmd(out))
- cmd.AddCommand(listTemplatesCmd(out))
- cmd.AddCommand(restartFailedCmd(out))
- cmd.AddCommand(resolveConflictsCmd(out))
- cmd.AddCommand(commentCmd(out))
- cmd.AddCommand(approveCmd(out))
- cmd.AddCommand(cleanupCmd(out))
-
- return cmd
-}
-
-func versionCmd() *cobra.Command {
- return &cobra.Command{
- Use: "version",
- Short: "Print version information",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Printf("gh-pr version %s\n", version)
- },
- }
-}
tools/gh-pr/cmd/gh-pr/resolve_conflicts.go
@@ -1,244 +0,0 @@
-package main
-
-import (
- "bufio"
- "fmt"
- "os"
- "os/exec"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/conflicts"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func resolveConflictsCmd(out *output.Writer) *cobra.Command {
- var (
- worktreeDir string
- noWorktree bool
- noPush bool
- org string
- author string
- )
-
- cmd := &cobra.Command{
- Use: "resolve-conflicts [REPOSITORY[#PR_NUMBER]]",
- Short: "Resolve merge conflicts in pull requests",
- Long: `List pull requests with merge conflicts and resolve them interactively.
-
-This command helps you resolve merge conflicts in pull requests by:
- - Fetching the PR branch
- - Creating a worktree or using existing repo
- - Performing rebase against the base branch
- - Launching conflict resolution tools (emacs ediff or git mergetool)
- - Force-pushing resolved changes (optional)
-
-Examples:
- gh-pr resolve-conflicts # Search for your conflicting PRs
- gh-pr resolve-conflicts owner/repo#123 # Resolve specific PR
- gh-pr resolve-conflicts -o tektoncd # Filter by organization
- gh-pr resolve-conflicts -n # Use existing repo, no worktree
- gh-pr resolve-conflicts -N # Don't auto-push after resolution`,
- RunE: func(cmd *cobra.Command, args []string) error {
- var repo, prNumber string
-
- if len(args) > 0 {
- arg := args[0]
- if strings.Contains(arg, "#") {
- parts := strings.SplitN(arg, "#", 2)
- repo = parts[0]
- prNumber = parts[1]
- } else {
- repo = arg
- }
- }
-
- return runResolveConflicts(out, resolveConflictsOpts{
- worktreeDir: worktreeDir,
- useWorktree: !noWorktree,
- autoPush: !noPush,
- org: org,
- author: author,
- repo: repo,
- prNumber: prNumber,
- })
- },
- }
-
- cmd.Flags().StringVarP(&worktreeDir, "worktree", "w", "/tmp/gh-resolve-conflicts-worktrees", "Create worktrees in DIR")
- cmd.Flags().BoolVarP(&noWorktree, "no-worktree", "n", false, "Use existing repo instead of worktrees")
- cmd.Flags().BoolVarP(&noPush, "no-push", "N", false, "Do NOT auto-push after resolution")
- cmd.Flags().StringVarP(&org, "org", "o", "", "Filter PRs by organization")
- cmd.Flags().StringVarP(&author, "author", "a", "@me", "Filter PRs by author")
-
- return cmd
-}
-
-type resolveConflictsOpts struct {
- worktreeDir string
- useWorktree bool
- autoPush bool
- org string
- author string
- repo string
- prNumber string
-}
-
-func runResolveConflicts(out *output.Writer, opts resolveConflictsOpts) error {
- resolver := conflicts.NewResolver(out, opts.worktreeDir, opts.useWorktree, opts.autoPush)
-
- // If specific PR is provided, resolve it directly
- if opts.prNumber != "" {
- if opts.repo == "" {
- return fmt.Errorf("repository must be specified when using #PR_NUMBER")
- }
-
- pr, err := resolver.FindConflictingPR(opts.repo, opts.prNumber)
- if err != nil {
- return err
- }
-
- out.Success("PR #%d: %s", pr.Number, pr.Title)
- out.Println("")
-
- return resolver.ResolvePR(opts.repo, pr)
- }
-
- // Interactive mode: search for conflicting PRs
- prs, err := resolver.FindConflictingPRs(opts.org, opts.author)
- if err != nil {
- return err
- }
-
- if len(prs) == 0 {
- out.Success("No pull requests with merge conflicts found!")
- return nil
- }
-
- out.Success("Found %d pull request(s) with merge conflicts", len(prs))
-
- // 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)
- }
-
- // Build fzf input with formatted PR information
- var fzfInput strings.Builder
- prMap := make(map[int]*conflicts.PRInfo) // Map PR number to info for later lookup
-
- for _, pr := range prs {
- prMap[pr.Number] = &pr
- // Format: "#123 Title here (@author) [repo]"
- repo := pr.HeadRepository.NameWithOwner
- fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) [%s]\n",
- pr.Number, pr.Title, pr.Author.Login, repo))
- }
-
- // Build preview command for fzf
- // We need to extract the repo from the selection and fetch PR details
- previewCmd := `echo {} | awk '{print $1, $(NF)}' | sed 's/\[//;s/\]//' | \
- xargs -I{} sh -c 'PR=$(echo {} | cut -d" " -f1); REPO=$(echo {} | cut -d" " -f2); \
- gh pr view $PR -R $REPO --json number,title,author,headRefName,baseRefName,mergeable,statusCheckRollup 2>/dev/null | \
- jq -r "\"# PR \" + (.number | tostring) + \": \" + .title,
- \"\",
- \"Author: @\" + .author.login,
- \"\",
- \"## Branches:\",
- \"\",
- \" \" + .headRefName + \" → \" + .baseRefName,
- \"\",
- \"## Merge Status: \" + (.mergeable // \"unknown\"),
- \"\",
- \"## Status Checks:\",
- \"\",
- (if (.statusCheckRollup // [] | length) == 0 then \" (No status checks)\"
- else (.statusCheckRollup | map(
- \" \" + (
- if .conclusion == \"SUCCESS\" then \"✓\"
- elif .conclusion == \"FAILURE\" then \"✗\"
- elif .conclusion == \"PENDING\" then \"●\"
- elif .conclusion == \"SKIPPED\" then \"○\"
- else \"?\"
- end
- ) + \" \" + .name + \" (\" + (.conclusion // \"unknown\") + \")\"
- ) | join(\"\\n\"))
- end)"'`
-
- // Use fzf for multi-select with preview
- out.Info("Select pull requests to resolve (use Tab to select multiple, Enter to confirm)...")
- fzfCmd := exec.Command("fzf",
- "--multi",
- "--ansi",
- "--header", "Select PRs to resolve conflicts (Tab: select, Enter: confirm)",
- "--preview", previewCmd,
- "--preview-window", "right:60%:wrap",
- )
- fzfCmd.Stdin = strings.NewReader(fzfInput.String())
- 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.")
- return nil
- }
- return fmt.Errorf("fzf selection failed: %w", err)
- }
-
- selectedPRs := strings.TrimSpace(string(selectedOutput))
- if selectedPRs == "" {
- out.Info("No pull requests selected.")
- return nil
- }
-
- // Extract PR numbers from selected lines
- selectedPRNumbers := []int{}
- scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
- for scanner.Scan() {
- line := scanner.Text()
- // Extract PR number from format "#123 Title..."
- if strings.HasPrefix(line, "#") {
- var prNum int
- if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
- selectedPRNumbers = append(selectedPRNumbers, prNum)
- }
- }
- }
-
- if len(selectedPRNumbers) == 0 {
- out.Warning("No valid PR numbers found in selection.")
- return nil
- }
-
- out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
- out.Println("")
-
- // Resolve selected PRs
- for _, prNum := range selectedPRNumbers {
- pr, ok := prMap[prNum]
- if !ok {
- out.Warning("PR #%d not found in the list, skipping...", prNum)
- continue
- }
-
- // Determine repository from PR
- repo := pr.HeadRepository.NameWithOwner
- if pr.IsCrossRepository {
- out.Warning("Skipping cross-repository PR #%d (requires manual handling)", prNum)
- continue
- }
-
- if err := resolver.ResolvePR(repo, pr); err != nil {
- out.Error("Failed to resolve PR #%d: %v", prNum, err)
- out.Println("")
- continue
- }
-
- out.Success("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
- out.Println("")
- }
-
- out.Success("Done!")
- return nil
-}
tools/gh-pr/cmd/gh-pr/restart_failed.go
@@ -1,379 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func restartFailedCmd(out *output.Writer) *cobra.Command {
- var (
- ignorePatterns []string
- labels []string
- repo string
- prNumber string
- )
-
- cmd := &cobra.Command{
- Use: "restart-failed [REPOSITORY[#PR_NUMBER]]",
- Short: "Restart failed workflow runs on pull requests",
- Long: `List pull requests with failed checks and restart selected workflows.
-
-By default, "Label Checker" workflows are ignored. Use --ignore to add more patterns.
-
-Examples:
- gh-pr restart-failed # Interactive mode
- gh-pr restart-failed owner/repo#123 # Restart specific PR
- gh-pr restart-failed --ignore build # Ignore "build" workflows
- gh-pr restart-failed --label bug # Filter by label`,
- RunE: func(cmd *cobra.Command, args []string) error {
- // Parse repository argument
- if len(args) > 0 {
- arg := args[0]
- if strings.Contains(arg, "#") {
- parts := strings.SplitN(arg, "#", 2)
- repo = parts[0]
- prNumber = parts[1]
- } else {
- repo = arg
- }
- }
-
- return runRestartFailed(out, restartFailedOpts{
- ignorePatterns: append([]string{"Label Checker"}, ignorePatterns...),
- labels: labels,
- repo: repo,
- prNumber: prNumber,
- })
- },
- }
-
- cmd.Flags().StringSliceVarP(&ignorePatterns, "ignore", "i", nil, "Ignore workflows matching pattern")
- cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Filter PRs by label")
-
- return cmd
-}
-
-type restartFailedOpts struct {
- ignorePatterns []string
- labels []string
- repo string
- prNumber string
-}
-
-type prInfo struct {
- Number int `json:"number"`
- Title string `json:"title"`
- HeadRefName string `json:"headRefName"`
- Author map[string]interface{} `json:"author"`
- StatusCheckRollup []checkStatus `json:"statusCheckRollup"`
-}
-
-type checkStatus struct {
- Name string `json:"name"`
- Conclusion string `json:"conclusion"`
-}
-
-type workflowRun struct {
- DatabaseID int `json:"databaseId"`
- Name string `json:"name"`
- Conclusion string `json:"conclusion"`
- Status string `json:"status"`
- Event string `json:"event"`
-}
-
-func runRestartFailed(out *output.Writer, opts restartFailedOpts) error {
- // Show what we're ignoring
- if len(opts.ignorePatterns) > 0 {
- out.Warning("Ignoring workflows matching: %s", strings.Join(opts.ignorePatterns, ", "))
- }
-
- // If specific PR is provided, restart it directly
- if opts.prNumber != "" {
- return restartSpecificPR(out, opts)
- }
-
- // Interactive mode: list and select PRs
- return restartInteractive(out, opts)
-}
-
-func restartSpecificPR(out *output.Writer, opts restartFailedOpts) error {
- out.Info("Fetching PR #%s...", opts.prNumber)
-
- // Build gh command
- args := []string{"pr", "view", opts.prNumber}
- if opts.repo != "" {
- args = append(args, "-R", opts.repo)
- }
- args = append(args, "--json", "number,title,headRefName,author")
-
- cmd := exec.Command("gh", args...)
- output, err := cmd.Output()
- if err != nil {
- return fmt.Errorf("failed to fetch PR: %w", err)
- }
-
- var pr prInfo
- if err := json.Unmarshal(output, &pr); err != nil {
- return fmt.Errorf("failed to parse PR info: %w", err)
- }
-
- out.Success("PR #%d: %s", pr.Number, pr.Title)
-
- return restartPRWorkflows(out, opts, pr.Number, pr.HeadRefName)
-}
-
-func restartInteractive(out *output.Writer, opts restartFailedOpts) 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)
- }
-
- out.Info("Fetching pull requests...")
-
- // Build gh pr list command
- args := []string{"pr", "list"}
- if opts.repo != "" {
- args = append(args, "-R", opts.repo)
- }
- for _, label := range opts.labels {
- args = append(args, "--label", label)
- }
- args = append(args, "--json", "number,title,headRefName,author,statusCheckRollup", "--limit", "100")
-
- cmd := exec.Command("gh", args...)
- output, err := cmd.Output()
- if err != nil {
- return fmt.Errorf("failed to list PRs: %w", err)
- }
-
- var prs []prInfo
- if err := json.Unmarshal(output, &prs); err != nil {
- return fmt.Errorf("failed to parse PRs: %w", err)
- }
-
- // Filter PRs with failed checks
- type failedPRInfo struct {
- pr prInfo
- failedCount int
- author string
- }
-
- failedPRs := []failedPRInfo{}
- for _, pr := range prs {
- failedCount := 0
- for _, check := range pr.StatusCheckRollup {
- if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" ||
- check.Conclusion == "STARTUP_FAILURE" || check.Conclusion == "ACTION_REQUIRED" {
- failedCount++
- }
- }
- if failedCount > 0 {
- author := "unknown"
- if login, ok := pr.Author["login"].(string); ok {
- author = login
- }
- failedPRs = append(failedPRs, failedPRInfo{
- pr: pr,
- failedCount: failedCount,
- author: author,
- })
- }
- }
-
- if len(failedPRs) == 0 {
- out.Success("No pull requests with failed checks found!")
- return nil
- }
-
- out.Success("Found %d pull request(s) with failed checks", len(failedPRs))
-
- // Build fzf input with formatted PR information
- var fzfInput strings.Builder
- prMap := make(map[int]failedPRInfo) // Map PR number to info for later lookup
-
- for _, fpr := range failedPRs {
- prMap[fpr.pr.Number] = fpr
- // Format: "#123 Title here (@author) - 2 failed"
- fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) - %d failed\n",
- fpr.pr.Number, fpr.pr.Title, fpr.author, fpr.failedCount))
- }
-
- // Build preview command for fzf
- repoFlag := ""
- if opts.repo != "" {
- repoFlag = fmt.Sprintf("-R %s", opts.repo)
- }
-
- previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
- jq -r '"# PR " + (.number | tostring) + ": " + .title,
- "",
- "Author: @" + .author.login,
- "",
- "## Status Checks:",
- "",
- (.statusCheckRollup // [] | map(
- " " + (
- if .conclusion == "SUCCESS" then "✓"
- elif .conclusion == "FAILURE" then "✗"
- elif .conclusion == "PENDING" then "●"
- elif .conclusion == "SKIPPED" then "○"
- else "?"
- end
- ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
- ) | join("\n"))'`, repoFlag)
-
- // Use fzf for multi-select with preview
- out.Info("Select pull requests to restart (use Tab to select multiple, Enter to confirm)...")
- fzfCmd := exec.Command("fzf",
- "--multi",
- "--ansi",
- "--header", "Select PRs to restart workflows (Tab: select, Enter: confirm)",
- "--preview", previewCmd,
- "--preview-window", "right:60%:wrap",
- )
- fzfCmd.Stdin = strings.NewReader(fzfInput.String())
- 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.")
- return nil
- }
- return fmt.Errorf("fzf selection failed: %w", err)
- }
-
- selectedPRs := strings.TrimSpace(string(selectedOutput))
- if selectedPRs == "" {
- out.Info("No pull requests selected.")
- return nil
- }
-
- // Extract PR numbers from selected lines
- selectedPRNumbers := []int{}
- scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
- for scanner.Scan() {
- line := scanner.Text()
- // Extract PR number from format "#123 Title..."
- if strings.HasPrefix(line, "#") {
- var prNum int
- if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
- selectedPRNumbers = append(selectedPRNumbers, prNum)
- }
- }
- }
-
- if len(selectedPRNumbers) == 0 {
- out.Warning("No valid PR numbers found in selection.")
- return nil
- }
-
- out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
- out.Println("")
-
- // Restart workflows for selected PRs
- for _, prNum := range selectedPRNumbers {
- fpr, ok := prMap[prNum]
- if !ok {
- out.Warning("PR #%d not found in the list, skipping...", prNum)
- continue
- }
-
- out.Info("PR #%d: %s", fpr.pr.Number, fpr.pr.Title)
- if err := restartPRWorkflows(out, opts, fpr.pr.Number, fpr.pr.HeadRefName); err != nil {
- out.Error("Failed to restart workflows: %v", err)
- }
- out.Println("")
- }
-
- out.Success("Done!")
- return nil
-}
-
-func restartPRWorkflows(out *output.Writer, opts restartFailedOpts, prNumber int, branch string) error {
- // Get failed workflow runs for this PR
- args := []string{"run", "list", "--branch", branch}
- if opts.repo != "" {
- args = append(args, "-R", opts.repo)
- }
- args = append(args, "--json", "databaseId,name,conclusion,status,event", "--limit", "50")
-
- cmd := exec.Command("gh", args...)
- output, err := cmd.Output()
- if err != nil {
- return fmt.Errorf("failed to list workflow runs: %w", err)
- }
-
- var runs []workflowRun
- if err := json.Unmarshal(output, &runs); err != nil {
- return fmt.Errorf("failed to parse workflow runs: %w", err)
- }
-
- // Filter failed runs
- failedRuns := []workflowRun{}
- for _, run := range runs {
- // Check if it's a PR event and failed
- if run.Event != "pull_request" {
- continue
- }
-
- if run.Conclusion != "failure" && run.Conclusion != "timed_out" &&
- run.Conclusion != "startup_failure" && run.Conclusion != "action_required" {
- continue
- }
-
- // Check ignore patterns
- ignored := false
- for _, pattern := range opts.ignorePatterns {
- if strings.Contains(run.Name, pattern) {
- ignored = true
- break
- }
- }
-
- if !ignored {
- failedRuns = append(failedRuns, run)
- }
- }
-
- if len(failedRuns) == 0 {
- out.Warning(" No failed workflow runs found (may have been restarted already)")
- return nil
- }
-
- out.Info(" Restarting %d failed workflow(s):", len(failedRuns))
-
- // Restart each failed workflow
- for _, run := range failedRuns {
- out.Print(" → Restarting: %s (%s)... ", run.Name, run.Conclusion)
-
- rerunArgs := []string{"run", "rerun", fmt.Sprintf("%d", run.DatabaseID), "--failed"}
- if opts.repo != "" {
- rerunArgs = append(rerunArgs, "-R", opts.repo)
- }
-
- rerunCmd := exec.Command("gh", rerunArgs...)
- rerunOutput, err := rerunCmd.CombinedOutput()
- outputStr := strings.TrimSpace(string(rerunOutput))
-
- if err != nil || strings.Contains(outputStr, "error") {
- if strings.Contains(outputStr, "created over a month ago") {
- out.Warning("⚠ Cannot restart: workflow run is too old (>1 month)")
- } else {
- out.Error("✗ Failed: %s", outputStr)
- }
- } else {
- out.Success("✓")
- }
- }
-
- return nil
-}
tools/gh-pr/internal/cache/cache.go
@@ -1,129 +0,0 @@
-package cache
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "time"
-)
-
-const (
- // DefaultTTL is the default time-to-live for cache entries (1 week)
- DefaultTTL = 7 * 24 * time.Hour
-
- // CacheDir is the directory where cache files are stored
- cacheDir = ".cache/gh-pr"
-)
-
-// Entry represents a cached item with expiration
-type Entry struct {
- Data interface{} `json:"data"`
- ExpiresAt time.Time `json:"expires_at"`
-}
-
-// Cache handles caching of data with TTL support
-type Cache struct {
- baseDir string
- ttl time.Duration
-}
-
-// New creates a new Cache instance
-func New(ttl time.Duration) (*Cache, error) {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return nil, err
- }
-
- baseDir := filepath.Join(homeDir, cacheDir)
- if err := os.MkdirAll(baseDir, 0755); err != nil {
- return nil, err
- }
-
- if ttl == 0 {
- ttl = DefaultTTL
- }
-
- return &Cache{
- baseDir: baseDir,
- ttl: ttl,
- }, nil
-}
-
-// Get retrieves a value from cache
-// Returns nil if not found or expired
-func (c *Cache) Get(key string, dest interface{}) error {
- filePath := filepath.Join(c.baseDir, key+".json")
-
- data, err := os.ReadFile(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return nil
- }
- return err
- }
-
- var entry Entry
- if err := json.Unmarshal(data, &entry); err != nil {
- return err
- }
-
- // Check if expired
- if time.Now().After(entry.ExpiresAt) {
- // Clean up expired entry
- _ = os.Remove(filePath)
- return nil
- }
-
- // Unmarshal the data into the destination
- dataBytes, err := json.Marshal(entry.Data)
- if err != nil {
- return err
- }
-
- return json.Unmarshal(dataBytes, dest)
-}
-
-// Set stores a value in cache with the configured TTL
-func (c *Cache) Set(key string, value interface{}) error {
- entry := Entry{
- Data: value,
- ExpiresAt: time.Now().Add(c.ttl),
- }
-
- data, err := json.Marshal(entry)
- if err != nil {
- return err
- }
-
- filePath := filepath.Join(c.baseDir, key+".json")
- return os.WriteFile(filePath, data, 0644)
-}
-
-// Delete removes an entry from cache
-func (c *Cache) Delete(key string) error {
- filePath := filepath.Join(c.baseDir, key+".json")
- err := os.Remove(filePath)
- if os.IsNotExist(err) {
- return nil
- }
- return err
-}
-
-// Clear removes all cache entries
-func (c *Cache) Clear() error {
- entries, err := os.ReadDir(c.baseDir)
- if err != nil {
- return err
- }
-
- for _, entry := range entries {
- if !entry.IsDir() {
- filePath := filepath.Join(c.baseDir, entry.Name())
- if err := os.Remove(filePath); err != nil {
- return err
- }
- }
- }
-
- return nil
-}
tools/gh-pr/internal/cache/cache_test.go
@@ -1,117 +0,0 @@
-package cache
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestCache(t *testing.T) {
- // Create temporary cache directory
- tmpDir := t.TempDir()
-
- c := &Cache{
- baseDir: tmpDir,
- ttl: 1 * time.Second,
- }
-
- t.Run("Set and Get", func(t *testing.T) {
- type testData struct {
- Name string
- Value int
- }
-
- original := testData{Name: "test", Value: 42}
-
- if err := c.Set("test-key", original); err != nil {
- t.Fatalf("Set failed: %v", err)
- }
-
- var retrieved testData
- if err := c.Get("test-key", &retrieved); err != nil {
- t.Fatalf("Get failed: %v", err)
- }
-
- if retrieved.Name != original.Name || retrieved.Value != original.Value {
- t.Errorf("Retrieved data mismatch: got %+v, want %+v", retrieved, original)
- }
- })
-
- t.Run("Get non-existent key", func(t *testing.T) {
- var data string
- if err := c.Get("non-existent", &data); err != nil {
- t.Fatalf("Get should not error on non-existent key: %v", err)
- }
- if data != "" {
- t.Errorf("Expected empty data for non-existent key, got: %s", data)
- }
- })
-
- t.Run("Expiration", func(t *testing.T) {
- if err := c.Set("expire-test", "value"); err != nil {
- t.Fatalf("Set failed: %v", err)
- }
-
- // Wait for expiration
- time.Sleep(2 * time.Second)
-
- var data string
- if err := c.Get("expire-test", &data); err != nil {
- t.Fatalf("Get failed: %v", err)
- }
-
- if data != "" {
- t.Errorf("Expected empty data after expiration, got: %s", data)
- }
-
- // Verify file was cleaned up
- filePath := filepath.Join(tmpDir, "expire-test.json")
- if _, err := os.Stat(filePath); !os.IsNotExist(err) {
- t.Error("Expected cache file to be deleted after expiration")
- }
- })
-
- t.Run("Delete", func(t *testing.T) {
- if err := c.Set("delete-test", "value"); err != nil {
- t.Fatalf("Set failed: %v", err)
- }
-
- if err := c.Delete("delete-test"); err != nil {
- t.Fatalf("Delete failed: %v", err)
- }
-
- var data string
- if err := c.Get("delete-test", &data); err != nil {
- t.Fatalf("Get failed: %v", err)
- }
-
- if data != "" {
- t.Errorf("Expected empty data after delete, got: %s", data)
- }
- })
-
- t.Run("Clear", func(t *testing.T) {
- // Set multiple entries
- for i := 0; i < 3; i++ {
- key := filepath.Join("clear-test", string(rune('a'+i)))
- if err := c.Set(key, i); err != nil {
- t.Fatalf("Set failed: %v", err)
- }
- }
-
- if err := c.Clear(); err != nil {
- t.Fatalf("Clear failed: %v", err)
- }
-
- // Verify all entries are gone
- entries, err := os.ReadDir(tmpDir)
- if err != nil {
- t.Fatalf("ReadDir failed: %v", err)
- }
-
- if len(entries) != 0 {
- t.Errorf("Expected 0 cache entries after clear, got %d", len(entries))
- }
- })
-}
tools/gh-pr/internal/conflicts/conflicts.go
@@ -1,467 +0,0 @@
-package conflicts
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-// Resolver handles merge conflict resolution
-type Resolver struct {
- out *output.Writer
- worktreeDir string
- useWorktree bool
- autoPush bool
-}
-
-// NewResolver creates a new conflict resolver
-func NewResolver(out *output.Writer, worktreeDir string, useWorktree, autoPush bool) *Resolver {
- return &Resolver{
- out: out,
- worktreeDir: worktreeDir,
- useWorktree: useWorktree,
- autoPush: autoPush,
- }
-}
-
-// PRInfo contains information about a pull request
-type PRInfo struct {
- Number int `json:"number"`
- Title string `json:"title"`
- HeadRefName string `json:"headRefName"`
- BaseRefName string `json:"baseRefName"`
- Mergeable string `json:"mergeable"`
- URL string `json:"url"`
- IsCrossRepository bool `json:"isCrossRepository"`
- HeadRepository struct {
- Name string `json:"name"`
- NameWithOwner string `json:"nameWithOwner"`
- } `json:"headRepository"`
- HeadRepositoryOwner struct {
- Login string `json:"login"`
- } `json:"headRepositoryOwner"`
- Author struct {
- Login string `json:"login"`
- } `json:"author"`
-}
-
-// FindConflictingPR finds a specific PR and checks if it has conflicts
-func (r *Resolver) FindConflictingPR(repo, prNumber string) (*PRInfo, error) {
- r.out.Info("Fetching PR #%s from %s...", prNumber, repo)
-
- args := []string{"pr", "view", prNumber, "-R", repo,
- "--json", "number,title,headRefName,baseRefName,author,mergeable,url,isCrossRepository,headRepository,headRepositoryOwner"}
-
- cmd := exec.Command("gh", args...)
- output, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("failed to fetch PR: %w", err)
- }
-
- var pr PRInfo
- if err := json.Unmarshal(output, &pr); err != nil {
- return nil, fmt.Errorf("failed to parse PR info: %w", err)
- }
-
- if pr.Mergeable != "CONFLICTING" {
- return nil, fmt.Errorf("PR #%s does not have merge conflicts (status: %s)", prNumber, pr.Mergeable)
- }
-
- return &pr, nil
-}
-
-// FindConflictingPRs searches for all conflicting PRs for a given author/org
-func (r *Resolver) FindConflictingPRs(org, author string) ([]PRInfo, error) {
- r.out.Info("Searching for conflicting pull requests...")
-
- // Build search query
- args := []string{"search", "prs", "--author", author, "--state", "open"}
- if org != "" {
- args = append(args, "--owner", org)
- }
- args = append(args, "--json", "number,title,repository,url", "--limit", "100")
-
- cmd := exec.Command("gh", args...)
- output, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("failed to search PRs: %w", err)
- }
-
- var searchResults []struct {
- Number int `json:"number"`
- Title string `json:"title"`
- URL string `json:"url"`
- Repository struct {
- NameWithOwner string `json:"nameWithOwner"`
- } `json:"repository"`
- }
-
- if err := json.Unmarshal(output, &searchResults); err != nil {
- return nil, fmt.Errorf("failed to parse search results: %w", err)
- }
-
- if len(searchResults) == 0 {
- return nil, nil
- }
-
- r.out.Info("Checking %d PRs for merge conflicts...", len(searchResults))
-
- var conflictingPRs []PRInfo
- for i, result := range searchResults {
- r.out.Print("\rChecking PR %d/%d...", i+1, len(searchResults))
-
- // Fetch detailed PR info
- pr, err := r.FindConflictingPR(result.Repository.NameWithOwner, fmt.Sprintf("%d", result.Number))
- if err != nil {
- // Skip non-conflicting PRs
- continue
- }
-
- conflictingPRs = append(conflictingPRs, *pr)
- }
-
- fmt.Println() // Newline after progress
- return conflictingPRs, nil
-}
-
-// ResolvePR resolves conflicts for a single PR
-func (r *Resolver) ResolvePR(repo string, pr *PRInfo) error {
- r.out.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
- r.out.Info("Repository: %s", repo)
- r.out.Info("PR #%d: %s", pr.Number, pr.Title)
- r.out.Info("Branch: %s -> %s", pr.HeadRefName, pr.BaseRefName)
- r.out.Info("URL: %s", pr.URL)
- r.out.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
- r.out.Println("")
-
- // Determine fork repository
- var forkRepo string
- if pr.IsCrossRepository {
- forkRepo = pr.HeadRepository.NameWithOwner
- if forkRepo == "" {
- forkRepo = fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
- }
- r.out.Info("PR is from fork: %s", forkRepo)
- } else {
- forkRepo = repo
- r.out.Info("PR is from branch in same repo")
- }
-
- var workDir string
- var err error
-
- if r.useWorktree {
- workDir, err = r.setupWorktree(repo, forkRepo, pr)
- if err != nil {
- return err
- }
- } else {
- workDir, err = r.setupExistingRepo(repo, forkRepo, pr)
- if err != nil {
- return err
- }
- }
-
- // Change to work directory
- if err := os.Chdir(workDir); err != nil {
- return fmt.Errorf("failed to change to work directory: %w", err)
- }
-
- // Perform rebase
- if err := r.performRebase(repo, pr, forkRepo); err != nil {
- return err
- }
-
- // Push changes
- if r.autoPush {
- r.out.Info("Force-pushing changes...")
- cmd := exec.Command("git", "push", "--force-with-lease")
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Run(); err != nil {
- r.out.Error("Failed to push changes")
- r.out.Warning("You may need to push manually from: %s", workDir)
- return err
- }
- r.out.Success("✓ Changes pushed successfully!")
- } else {
- r.out.Warning("Changes not pushed. To push manually:")
- r.out.Println(" cd %s", workDir)
- r.out.Println(" git push --force-with-lease")
- }
-
- r.out.Println("")
-
- if r.useWorktree {
- r.out.Warning("Note: Worktree kept at: %s", workDir)
- r.out.Warning("To remove: git worktree remove %s", workDir)
- }
-
- return nil
-}
-
-func (r *Resolver) setupWorktree(repo, forkRepo string, pr *PRInfo) (string, error) {
- repoName := strings.ReplaceAll(repo, "/", "-")
- workDir := filepath.Join(r.worktreeDir, repoName, fmt.Sprintf("pr-%d", pr.Number))
-
- r.out.Info("Creating worktree at: %s", workDir)
-
- // Create parent directory
- if err := os.MkdirAll(filepath.Join(r.worktreeDir, repoName), 0755); err != nil {
- return "", fmt.Errorf("failed to create worktree directory: %w", err)
- }
-
- // Clone/fetch repository
- repoDir := filepath.Join(r.worktreeDir, repoName, "main")
- if _, err := os.Stat(repoDir); os.IsNotExist(err) {
- r.out.Info("Cloning fork: %s...", forkRepo)
- cmd := exec.Command("gh", "repo", "clone", forkRepo, repoDir, "--", "--bare")
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to clone repository: %w", err)
- }
-
- // Add upstream remote if this is a fork
- if pr.IsCrossRepository {
- r.out.Info("Adding upstream remote: %s...", repo)
- cmd := exec.Command("git", "-C", repoDir, "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
- cmd.Run() // Ignore error if already exists
- }
- } else {
- r.out.Info("Fetching latest changes from fork...")
- cmd := exec.Command("git", "-C", repoDir, "fetch", "origin")
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to fetch: %w", err)
- }
-
- // Ensure upstream exists
- if pr.IsCrossRepository {
- cmd := exec.Command("git", "-C", repoDir, "remote", "get-url", "upstream")
- if err := cmd.Run(); err != nil {
- r.out.Info("Adding upstream remote: %s...", repo)
- cmd := exec.Command("git", "-C", repoDir, "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
- cmd.Run()
- }
- }
- }
-
- // Fetch from upstream if fork
- if pr.IsCrossRepository {
- r.out.Info("Fetching from upstream: %s...", repo)
- cmd := exec.Command("git", "-C", repoDir, "fetch", "upstream")
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to fetch upstream: %w", err)
- }
- }
-
- // Remove existing worktree if present
- if _, err := os.Stat(workDir); err == nil {
- r.out.Info("Removing existing worktree...")
- cmd := exec.Command("git", "-C", repoDir, "worktree", "remove", workDir, "--force")
- cmd.Run() // Ignore error
- os.RemoveAll(workDir)
- }
-
- // Fetch PR branch
- r.out.Info("Fetching PR branch: %s...", pr.HeadRefName)
- cmd := exec.Command("git", "-C", repoDir, "fetch", "origin", fmt.Sprintf("%s:pr-%d", pr.HeadRefName, pr.Number))
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to fetch PR branch: %w", err)
- }
-
- // Create worktree
- r.out.Info("Creating worktree for branch %s...", pr.HeadRefName)
- cmd = exec.Command("git", "-C", repoDir, "worktree", "add", workDir, fmt.Sprintf("pr-%d", pr.Number))
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to create worktree: %w", err)
- }
-
- return workDir, nil
-}
-
-func (r *Resolver) setupExistingRepo(repo, forkRepo string, pr *PRInfo) (string, error) {
- r.out.Info("Using existing repository (no worktree)")
-
- cwd, err := os.Getwd()
- if err != nil {
- return "", err
- }
-
- // Verify we're in the correct repository
- cmd := exec.Command("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")
- output, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("failed to get current repo: %w", err)
- }
-
- currentRepo := strings.TrimSpace(string(output))
- if currentRepo != forkRepo {
- return "", fmt.Errorf("current directory is %s, expected %s. Please cd to your fork or use --worktree mode", currentRepo, forkRepo)
- }
-
- // Ensure upstream remote exists if fork
- if pr.IsCrossRepository {
- cmd := exec.Command("git", "remote", "get-url", "upstream")
- if err := cmd.Run(); err != nil {
- r.out.Info("Adding upstream remote: %s...", repo)
- cmd := exec.Command("git", "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to add upstream: %w", err)
- }
- }
-
- r.out.Info("Fetching from upstream...")
- cmd = exec.Command("git", "fetch", "upstream")
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to fetch upstream: %w", err)
- }
- }
-
- // Checkout PR
- r.out.Info("Checking out PR #%d...", pr.Number)
- cmd = exec.Command("gh", "pr", "checkout", fmt.Sprintf("%d", pr.Number), "-R", repo)
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("failed to checkout PR: %w", err)
- }
-
- return cwd, nil
-}
-
-func (r *Resolver) performRebase(repo string, pr *PRInfo, forkRepo string) error {
- // Determine base remote
- baseRemote := "origin"
- if pr.IsCrossRepository {
- baseRemote = "upstream"
- }
-
- // Fetch base branch
- r.out.Info("Fetching base branch from %s: %s", baseRemote, pr.BaseRefName)
- cmd := exec.Command("git", "fetch", baseRemote, pr.BaseRefName)
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("failed to fetch base branch: %w", err)
- }
-
- // Start rebase
- r.out.Info("Starting rebase onto %s/%s...", baseRemote, pr.BaseRefName)
- r.out.Println("")
-
- cmd = exec.Command("git", "rebase", fmt.Sprintf("%s/%s", baseRemote, pr.BaseRefName))
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err == nil {
- r.out.Success("✓ Rebase completed successfully with no conflicts!")
- return nil
- }
-
- // Conflicts detected
- r.out.Warning("Conflicts detected. Starting conflict resolution...")
- r.out.Println("")
-
- // Get conflicted files
- cmd = exec.Command("git", "diff", "--name-only", "--diff-filter=U")
- output, err := cmd.Output()
- if err != nil {
- r.out.Error("Failed to get conflicted files")
- exec.Command("git", "rebase", "--abort").Run()
- return fmt.Errorf("failed to get conflicted files: %w", err)
- }
-
- conflictedFiles := strings.Split(strings.TrimSpace(string(output)), "\n")
- if len(conflictedFiles) == 0 || conflictedFiles[0] == "" {
- r.out.Error("Rebase failed but no conflicted files found")
- exec.Command("git", "rebase", "--abort").Run()
- return fmt.Errorf("rebase failed with no conflicts")
- }
-
- r.out.Info("Conflicted files:")
- for _, file := range conflictedFiles {
- r.out.Error(" ✗ %s", file)
- }
- r.out.Println("")
-
- // Resolve conflicts
- for _, file := range conflictedFiles {
- if err := r.resolveConflict(file); err != nil {
- r.out.Error("Failed to resolve conflict in %s: %v", file, err)
- exec.Command("git", "rebase", "--abort").Run()
- return fmt.Errorf("conflict resolution failed: %w", err)
- }
- }
-
- // Continue rebase
- r.out.Info("Continuing rebase...")
- cmd = exec.Command("git", "rebase", "--continue")
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Run(); err != nil {
- r.out.Error("Failed to continue rebase")
- r.out.Warning("You may need to resolve remaining conflicts manually")
- return fmt.Errorf("failed to continue rebase: %w", err)
- }
-
- r.out.Println("")
- r.out.Success("✓ Conflicts resolved successfully!")
- r.out.Println("")
-
- return nil
-}
-
-func (r *Resolver) resolveConflict(file string) error {
- r.out.Info("Resolving: %s", file)
-
- // Check if emacs is available
- if _, err := exec.LookPath("emacs"); err == nil {
- // Use emacs ediff
- r.out.Success("Launching emacs ediff for: %s", file)
-
- // Use git mergetool with emacs
- cmd := exec.Command("git", "mergetool", "--tool=emerge", file)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- r.out.Warning("Emacs ediff failed, falling back to default mergetool")
- } else {
- // Check if resolved
- if !r.hasConflictMarkers(file) {
- cmd := exec.Command("git", "add", file)
- cmd.Run()
- return nil
- }
- }
- }
-
- // Fallback to default git mergetool
- r.out.Info("Using git mergetool...")
- cmd := exec.Command("git", "mergetool", file)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- return err
- }
-
- // Verify resolution
- if r.hasConflictMarkers(file) {
- return fmt.Errorf("conflict markers still present in %s", file)
- }
-
- // Stage file
- cmd = exec.Command("git", "add", file)
- return cmd.Run()
-}
-
-func (r *Resolver) hasConflictMarkers(file string) bool {
- content, err := os.ReadFile(file)
- if err != nil {
- return false
- }
- return strings.Contains(string(content), "<<<<<<<")
-}
tools/gh-pr/internal/output/output.go
@@ -1,80 +0,0 @@
-package output
-
-import (
- "fmt"
- "io"
- "os"
-)
-
-// Color codes for terminal output
-const (
- Red = "\033[0;31m"
- Green = "\033[0;32m"
- Yellow = "\033[1;33m"
- Blue = "\033[0;34m"
- Reset = "\033[0m"
-)
-
-// Writer provides colored output methods
-type Writer struct {
- out io.Writer
- err io.Writer
- colors bool
-}
-
-// NewWriter creates a new output writer
-func NewWriter(out, err io.Writer, colors bool) *Writer {
- return &Writer{
- out: out,
- err: err,
- colors: colors,
- }
-}
-
-// Default creates a writer that outputs to stdout/stderr with colors
-func Default() *Writer {
- return NewWriter(os.Stdout, os.Stderr, true)
-}
-
-// colorize wraps text in color codes if colors are enabled
-func (w *Writer) colorize(color, text string) string {
- if !w.colors {
- return text
- }
- return color + text + Reset
-}
-
-// Info prints an informational message
-func (w *Writer) Info(format string, args ...interface{}) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintln(w.out, w.colorize(Blue, msg))
-}
-
-// Success prints a success message
-func (w *Writer) Success(format string, args ...interface{}) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintln(w.out, w.colorize(Green, msg))
-}
-
-// Warning prints a warning message
-func (w *Writer) Warning(format string, args ...interface{}) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintln(w.err, w.colorize(Yellow, msg))
-}
-
-// Error prints an error message
-func (w *Writer) Error(format string, args ...interface{}) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintln(w.err, w.colorize(Red, msg))
-}
-
-// Print prints a message without color
-func (w *Writer) Print(format string, args ...interface{}) {
- fmt.Fprintf(w.out, format, args...)
-}
-
-// Println prints a message with newline without color
-func (w *Writer) Println(format string, args ...interface{}) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintln(w.out, msg)
-}
tools/gh-pr/internal/templates/templates.go
@@ -1,216 +0,0 @@
-package templates
-
-import (
- "crypto/sha256"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/vdemeester/home/tools/gh-pr/internal/cache"
-)
-
-// Template represents a PR template file
-type Template struct {
- Path string
- Name string
- Content string
-}
-
-// Finder finds and caches PR templates
-type Finder struct {
- cache *cache.Cache
-}
-
-// NewFinder creates a new template finder
-func NewFinder() (*Finder, error) {
- c, err := cache.New(cache.DefaultTTL)
- if err != nil {
- return nil, fmt.Errorf("failed to create cache: %w", err)
- }
-
- return &Finder{
- cache: c,
- }, nil
-}
-
-// Find locates all PR templates in the current repository
-// If refresh is true, bypasses cache and performs fresh search
-func (f *Finder) Find(refresh bool) ([]Template, error) {
- // Generate cache key based on current directory
- cwd, err := os.Getwd()
- if err != nil {
- return nil, err
- }
-
- cacheKey := f.generateCacheKey(cwd)
-
- // Try cache first unless refresh is requested
- if !refresh {
- var cached []Template
- if err := f.cache.Get(cacheKey, &cached); err == nil && cached != nil {
- return cached, nil
- }
- }
-
- // Search for templates
- templates, err := f.searchTemplates(".")
- if err != nil {
- return nil, err
- }
-
- // Cache the results
- if err := f.cache.Set(cacheKey, templates); err != nil {
- // Don't fail if caching fails, just log and continue
- fmt.Fprintf(os.Stderr, "Warning: failed to cache templates: %v\n", err)
- }
-
- return templates, nil
-}
-
-// FindInRepo locates all PR templates in a remote repository
-// The repo parameter should be in "owner/repo" format
-// If refresh is true, bypasses cache and performs fresh search
-func (f *Finder) FindInRepo(repo string, refresh bool) ([]Template, error) {
- cacheKey := f.generateCacheKey(fmt.Sprintf("remote:%s", repo))
-
- // Try cache first unless refresh is requested
- if !refresh {
- var cached []Template
- if err := f.cache.Get(cacheKey, &cached); err == nil && cached != nil {
- return cached, nil
- }
- }
-
- // Create temporary directory for cloning
- tmpDir, err := os.MkdirTemp("", "gh-pr-templates-*")
- if err != nil {
- return nil, fmt.Errorf("failed to create temp directory: %w", err)
- }
- defer os.RemoveAll(tmpDir)
-
- // Clone repository using gh (shallow clone for speed)
- cloneDir := filepath.Join(tmpDir, "repo")
- cmd := exec.Command("gh", "repo", "clone", repo, cloneDir, "--", "--depth", "1")
- if output, err := cmd.CombinedOutput(); err != nil {
- return nil, fmt.Errorf("failed to clone repository %s: %w\nOutput: %s", repo, err, string(output))
- }
-
- // Search for templates in the cloned repository
- templates, err := f.searchTemplates(cloneDir)
- if err != nil {
- return nil, err
- }
-
- // Update template paths to indicate they're from a remote repo
- for i := range templates {
- templates[i].Path = fmt.Sprintf("%s:%s", repo, templates[i].Path)
- }
-
- // Cache the results
- if err := f.cache.Set(cacheKey, templates); err != nil {
- // Don't fail if caching fails, just log and continue
- fmt.Fprintf(os.Stderr, "Warning: failed to cache templates: %v\n", err)
- }
-
- return templates, nil
-}
-
-// ClearCache removes cached template information
-func (f *Finder) ClearCache() error {
- return f.cache.Clear()
-}
-
-// generateCacheKey creates a unique cache key for the current repository
-func (f *Finder) generateCacheKey(dir string) string {
- h := sha256.New()
- h.Write([]byte(dir))
- h.Write([]byte(time.Now().Format("2006-01-02"))) // Include date for daily refresh
- return fmt.Sprintf("templates-%x", h.Sum(nil))
-}
-
-// searchTemplates performs the actual search for PR templates in a given base directory
-func (f *Finder) searchTemplates(baseDir string) ([]Template, error) {
- var templates []Template
-
- // Common locations for PR templates
- locations := []string{
- ".github/PULL_REQUEST_TEMPLATE.md",
- ".github/pull_request_template.md",
- ".github/PULL_REQUEST_TEMPLATE/",
- "docs/PULL_REQUEST_TEMPLATE.md",
- "docs/pull_request_template.md",
- }
-
- for _, loc := range locations {
- fullPath := filepath.Join(baseDir, loc)
- info, err := os.Stat(fullPath)
- if err != nil {
- continue
- }
-
- if info.IsDir() {
- // List all markdown files in the directory
- entries, err := os.ReadDir(fullPath)
- if err != nil {
- continue
- }
-
- for _, entry := range entries {
- if entry.IsDir() {
- continue
- }
-
- name := entry.Name()
- if !strings.HasSuffix(name, ".md") {
- continue
- }
-
- path := filepath.Join(fullPath, name)
- content, err := os.ReadFile(path)
- if err != nil {
- continue
- }
-
- // Use relative path for display
- relPath := filepath.Join(loc, name)
- templates = append(templates, Template{
- Path: relPath,
- Name: strings.TrimSuffix(name, ".md"),
- Content: string(content),
- })
- }
- } else {
- content, err := os.ReadFile(fullPath)
- if err != nil {
- continue
- }
-
- // Extract name from path
- name := filepath.Base(loc)
- name = strings.TrimSuffix(name, ".md")
- if name == "PULL_REQUEST_TEMPLATE" || name == "pull_request_template" {
- name = "default"
- }
-
- templates = append(templates, Template{
- Path: loc,
- Name: name,
- Content: string(content),
- })
- }
- }
-
- return templates, nil
-}
-
-// ReadTemplate reads a specific template file
-func ReadTemplate(path string) (string, error) {
- content, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("failed to read template %s: %w", path, err)
- }
- return string(content), nil
-}
tools/gh-pr/go.mod
@@ -1,10 +0,0 @@
-module github.com/vdemeester/home/tools/gh-pr
-
-go 1.23
-
-require github.com/spf13/cobra v1.8.1
-
-require (
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
-)
tools/gh-pr/go.sum
@@ -1,10 +0,0 @@
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tools/gh-pr/README.md
@@ -1,591 +0,0 @@
-# gh-pr
-
-A comprehensive GitHub Pull Request management tool written in Go, consolidating PR creation with template support, workflow management, and conflict resolution.
-
-## Features
-
-- **PR Creation with Templates**: Create pull requests with automatic template discovery and caching
-- **Template Management**: List and preview templates from local or remote repositories
-- **Remote Template Discovery**: Browse templates from any GitHub repository
-- **Batch Commenting**: Comment on multiple pull requests at once using fzf multi-select
-- **Workflow Restart**: Automatically restart failed GitHub Actions workflows
-- **Conflict Resolution**: Interactive merge conflict resolution with worktree support
-- **Template Caching**: Templates are cached for one week to speed up operations
-
-## Installation
-
-```bash
-# Build with Nix
-nix build .#gh-pr
-
-# Or install to your profile
-nix profile install .#gh-pr
-```
-
-## Commands
-
-### `gh-pr create`
-
-Create a pull request with optional template support.
-
-```bash
-# Create PR interactively (uses gh defaults)
-gh-pr create
-
-# Create PR with a specific template
-gh-pr create --template bug-fix
-
-# Create draft PR with title and body
-gh-pr create --title "Fix bug" --body "Description" --draft
-
-# Refresh template cache
-gh-pr create --refresh
-
-# Full example with all options
-gh-pr create \
- --title "Add new feature" \
- --template feature \
- --draft \
- --reviewer user1,user2 \
- --assignee user3 \
- --label enhancement,feature \
- --base main \
- --head feature-branch
-```
-
-**Options:**
-- `-t, --title`: Pull request title
-- `-b, --body`: Pull request body (overridden by template if both are provided)
-- `--template`: Template name or path
-- `-d, --draft`: Create as draft PR
-- `--base`: Base branch (default: repository default)
-- `--head`: Head branch (default: current branch)
-- `-w, --web`: Open in web browser
-- `-r, --reviewer`: Request reviewers (comma-separated)
-- `-a, --assignee`: Assign users (comma-separated)
-- `-l, --label`: Add labels (comma-separated)
-- `--refresh`: Bypass template cache and search again
-
-**Template Discovery:**
-
-Templates are automatically discovered from:
-- `.github/PULL_REQUEST_TEMPLATE.md`
-- `.github/PULL_REQUEST_TEMPLATE/`
-- `docs/PULL_REQUEST_TEMPLATE.md`
-
-### `gh-pr comment`
-
-Comment on multiple pull requests at once using interactive selection.
-
-```bash
-# Interactive mode - select PRs and enter comment
-gh-pr comment
-
-# Pre-specify the comment body
-gh-pr comment --body "LGTM! Approving this change."
-
-# Filter by label before selecting
-gh-pr comment --label bug --label urgent
-
-# Work with a specific repository
-gh-pr comment --repo owner/repo
-
-# Include closed PRs in the selection
-gh-pr comment --state all
-
-# Filter by author
-gh-pr comment --author username
-```
-
-**Options:**
-- `-b, --body`: Comment body (will prompt if not provided)
-- `-R, --repo`: Repository in "owner/repo" format
-- `-l, --label`: Filter PRs by label (can be used multiple times)
-- `-a, --author`: Filter PRs by author
-- `-s, --state`: Filter by state: open, closed, merged, all (default: open)
-
-**How It Works:**
-
-1. **List PRs**: Fetches pull requests matching your filters using `gh pr list`
-2. **Select**: Uses fzf for multi-select with preview pane showing:
- - PR title and author
- - CI check status with visual indicators (✓/✗/●/○)
- - Tab to select, Enter to confirm
-3. **Comment**: Prompts for comment body if not provided via `--body`
-4. **Post**: Posts the same comment to all selected PRs
-
-**Use Cases:**
-- Notify multiple PRs about a related change
-- Request updates across multiple related PRs
-- 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.
-
-```bash
-# List templates in current repository
-gh-pr list-templates
-
-# List templates from a remote repository
-gh-pr list-templates tektoncd/pipeline
-
-# Show template content preview
-gh-pr list-templates --verbose
-
-# Refresh cache and list templates
-gh-pr list-templates --refresh
-
-# Browse templates from any repo
-gh-pr list-templates kubernetes/kubernetes --verbose
-```
-
-**Options:**
-- `[REPOSITORY]`: Optional repository in "owner/repo" format to search
-- `--refresh`: Refresh template cache
-- `-v, --verbose`: Show template content preview
-
-**Remote Repository Support:**
-
-You can now browse templates from any GitHub repository without cloning it first! The tool will:
-1. Shallow clone the repository to a temporary directory
-2. Search for PR templates
-3. Cache the results for one week
-4. Clean up the temporary clone
-
-This is especially useful for:
-- Exploring templates from organizations you contribute to
-- Finding good template examples from popular projects
-- Quickly checking if a repository uses PR templates
-
-### `gh-pr restart-failed`
-
-Restart failed workflow runs on pull requests with interactive selection.
-
-```bash
-# Interactive mode - select PRs with failed checks using fzf
-gh-pr restart-failed
-
-# Restart workflows for a specific PR (no selection needed)
-gh-pr restart-failed owner/repo#123
-
-# Filter by label before selecting
-gh-pr restart-failed --label bug
-
-# Ignore specific workflows
-gh-pr restart-failed --ignore "build" --ignore "test"
-
-# Work with a specific repository
-gh-pr restart-failed owner/repo
-```
-
-**Options:**
-- `-i, --ignore`: Ignore workflows matching pattern (can be used multiple times)
-- `-l, --label`: Filter PRs by label (can be used multiple times)
-
-**How It Works:**
-
-1. **Find Failed PRs**: Fetches all PRs and filters those with failed checks
-2. **Select**: Uses fzf for multi-select with preview pane showing:
- - All CI check statuses with visual indicators (✓/✗/●/○)
- - Author information
- - Tab to select, Enter to confirm
-3. **Restart**: Restarts failed workflows for all selected PRs
-
-**Default Behavior:**
-- "Label Checker" workflows are ignored by default
-- Only restarts workflows that failed due to:
- - `failure`
- - `timed_out`
- - `startup_failure`
- - `action_required`
-- Shows failed count for each PR in the selection interface
-
-### `gh-pr resolve-conflicts`
-
-Resolve merge conflicts in pull requests interactively with full worktree support and fzf selection.
-
-```bash
-# Search for your conflicting PRs and select with fzf
-gh-pr resolve-conflicts
-
-# Resolve a specific PR (no selection)
-gh-pr resolve-conflicts owner/repo#123
-
-# Filter by organization before selecting
-gh-pr resolve-conflicts -o tektoncd
-
-# Use existing repo instead of creating worktree
-gh-pr resolve-conflicts -n
-
-# Don't auto-push after resolving
-gh-pr resolve-conflicts -N
-
-# Specify custom worktree directory
-gh-pr resolve-conflicts -w /tmp/my-worktrees
-```
-
-**Options:**
-- `-w, --worktree DIR`: Create worktrees in specified directory (default: `/tmp/gh-resolve-conflicts-worktrees`)
-- `-n, --no-worktree`: Use existing repo instead of creating worktrees
-- `-N, --no-push`: Don't automatically force-push after resolution
-- `-o, --org ORG`: Filter PRs by organization
-- `-a, --author USER`: Filter PRs by author (default: `@me`)
-
-**How It Works:**
-
-1. **Find Conflicting PRs**: Searches for open PRs with merge conflicts
-2. **Select**: Uses fzf for multi-select with preview pane showing:
- - Branch information (head → base)
- - Merge status
- - CI check status with visual indicators (✓/✗/●/○)
- - Tab to select, Enter to confirm
-3. **Setup Worktree**: Creates an isolated worktree for each PR (or uses existing repo)
-4. **Fetch Branches**: Fetches both the PR branch and upstream base branch
-5. **Rebase**: Attempts to rebase the PR onto the base branch
-6. **Resolve Conflicts**: Launches conflict resolution tool:
- - Tries `emacs` with ediff mode first
- - Falls back to `git mergetool` if emacs is unavailable
-7. **Force Push**: Optionally force-pushes the resolved changes
-
-**Fork Support:**
-
-The tool automatically handles forked repositories:
-- Detects cross-repository PRs
-- Adds upstream remote when needed
-- Fetches from both fork and upstream
-- Pushes to the correct fork after resolution
-
-**Worktree Benefits:**
-
-Using worktrees (default behavior) allows you to:
-- Resolve conflicts in isolated environments
-- Work on multiple PRs simultaneously
-- Keep your main repository clean
-- Easily discard worktrees after resolution
-
-### `gh-pr cleanup`
-
-Clean up git worktrees that were created during conflict resolution or other operations.
-
-```bash
-# Dry run - see what would be removed
-gh-pr cleanup --dry-run
-
-# Clean up worktrees in default directory
-gh-pr cleanup
-
-# Clean up worktrees in custom directory
-gh-pr cleanup ~/my-worktrees
-
-# Only remove worktrees where commits are merged upstream
-gh-pr cleanup --check-upstream
-
-# Only remove worktrees for merged/closed PRs
-gh-pr cleanup --check-merged
-
-# Check both upstream merge status and PR status
-gh-pr cleanup --check-upstream --check-merged
-
-# Use custom upstream branch for merge check
-gh-pr cleanup --check-upstream --upstream-branch master
-
-# Force remove even with uncommitted changes (dangerous!)
-gh-pr cleanup --force
-```
-
-**Options:**
-- `-w, --worktree`: Directory containing worktrees (default: `/tmp/gh-resolve-conflicts-worktrees`)
-- `-n, --dry-run`: Show what would be removed without actually removing
-- `-u, --check-upstream`: Check if commits from the branch are merged upstream
-- `-b, --upstream-branch`: Upstream branch to check against (default: `main`)
-- `-m, --check-merged`: Only remove worktrees for merged or closed PRs
-- `-f, --force`: Force remove even if there are uncommitted changes (use with caution!)
-- `[DIRECTORY]`: Positional argument to specify the worktree base directory
-
-**How It Works:**
-
-1. **Scan**: Searches for git repositories with worktrees in the specified directory
-2. **Analyze**: For each worktree:
- - Checks for uncommitted changes
- - Optionally checks if commits are merged upstream (with `--check-upstream`)
- - Optionally checks if the associated PR is merged/closed (with `--check-merged`)
-3. **Clean**: Removes worktrees based on merge status:
- - **Without checks**: Removes all clean worktrees
- - **With checks**: Only removes worktrees where commits are merged upstream or PR is merged
-4. **Report**: Shows summary with color-coded status:
- - ✅ Green checkmark: Merged upstream or PR merged (safe to remove)
- - 🗑️ Trash can: Clean worktree (standard removal)
- - ⚠️ Warning: Has uncommitted changes (keeping)
- - ℹ️ Info: Not merged or still open (keeping)
-
-**Merge Detection:**
-
-The `--check-upstream` flag uses `git cherry` to detect if all commits from a branch have been merged upstream:
-- Compares commits between the worktree branch and the upstream branch (default: `main`)
-- Considers a branch merged if all its commits exist in upstream
-- Works even if the PR was squash-merged or rebased
-- Different from PR status - detects actual commit presence in upstream
-
-**Use Cases:**
-- Clean up after resolving multiple PR conflicts
-- Remove stale worktrees from failed resolution attempts
-- Reclaim disk space from old PR worktrees
-- Batch cleanup of merged PR worktrees
-- Safely remove worktrees only when changes are actually merged
-- Clean up squash-merged PRs that may still show as "open"
-
-**Safety:**
-- By default, keeps worktrees with uncommitted changes
-- Use `--dry-run` first to preview what will be removed
-- Use `--check-upstream` to only remove when commits are safely merged
-- Use `--check-merged` to only remove worktrees for PRs that are already merged/closed
-- Combine both checks for maximum safety
-
-## Template Caching
-
-Templates are cached for **7 days** (one week) by default. This significantly speeds up operations when working with the same repository.
-
-**Cache Location:** `~/.cache/gh-pr/`
-
-**Cache Invalidation:**
-- Use `--refresh` flag on any command that uses templates
-- Cache automatically expires after 7 days
-- Manual deletion: `rm -rf ~/.cache/gh-pr/`
-
-## Architecture
-
-The tool is organized into several packages:
-
-```
-tools/gh-pr/
-├── cmd/gh-pr/ # Main command and subcommands
-│ ├── main.go # Entry point and root command
-│ ├── create.go # PR creation
-│ ├── list_templates.go # Template listing
-│ ├── restart_failed.go # Workflow restart
-│ ├── resolve_conflicts.go # Conflict resolution
-│ ├── cleanup.go # Worktree cleanup
-│ ├── comment.go # Batch commenting
-│ └── approve.go # Batch approval
-├── internal/
-│ ├── cache/ # Caching with TTL support
-│ ├── output/ # Colored terminal output
-│ └── templates/ # Template discovery and management
-├── go.mod
-├── default.nix # Nix package definition
-└── README.md
-```
-
-## Examples
-
-### Creating a PR from Claude Code
-
-When Claude suggests creating a PR, the workflow is streamlined:
-
-```bash
-# List available templates
-gh-pr list-templates
-
-# Create PR with a template
-gh-pr create --template feature --title "Add user authentication"
-
-# Create PR with custom content
-gh-pr create \
- --title "Implement OAuth login" \
- --body "Adds OAuth 2.0 support for Google and GitHub" \
- --draft \
- --reviewer team-lead
-```
-
-### Commenting on Multiple PRs
-
-```bash
-# Select and comment on multiple PRs interactively
-gh-pr comment
-
-# Comment on all PRs with a specific label
-gh-pr comment --label needs-rebase --body "Please rebase on main"
-
-# Notify all your open PRs about a breaking change
-gh-pr comment --author @me --body "Note: This depends on #1234"
-```
-
-### Restarting Failed Workflows
-
-```bash
-# Select PRs with failures and restart workflows
-gh-pr restart-failed
-
-# Restart specific PR in another repo (no selection)
-gh-pr restart-failed tektoncd/pipeline#1234
-
-# Filter and select PRs with specific label
-gh-pr restart-failed --label bug
-
-# Ignore flaky tests
-gh-pr restart-failed --ignore "e2e-tests"
-```
-
-### Cleaning Up Worktrees
-
-```bash
-# Preview what would be cleaned (dry run)
-gh-pr cleanup --dry-run
-
-# Clean up all clean worktrees
-gh-pr cleanup
-
-# Only remove worktrees where commits are merged upstream
-gh-pr cleanup --check-upstream
-
-# Only remove worktrees for merged PRs
-gh-pr cleanup --check-merged
-
-# Maximum safety: check both upstream and PR status
-gh-pr cleanup --check-upstream --check-merged --dry-run
-
-# Clean up a specific directory
-gh-pr cleanup ~/tekton-worktrees
-```
-
-## Integration with Existing Tools
-
-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`
-
-The old shell scripts are now deprecated in favor of this unified Go tool.
-
-## Development
-
-```bash
-# Run tests
-go test ./...
-
-# Format code
-go fmt ./...
-
-# Build locally
-go build -o gh-pr ./cmd/gh-pr
-
-# Build with Nix
-nix build .#gh-pr
-```
-
-## Dependencies
-
-- `gh` (GitHub CLI) - Required for all GitHub operations
-- `fzf` - Required for interactive PR selection with preview
-- `jq` - Required for formatting preview pane and JSON parsing
-- Go 1.23+ - For building from source
-
-## License
-
-MIT
-
-## Future Enhancements
-
-- [x] Full implementation of `resolve-conflicts` command
-- [x] Remote repository template discovery
-- [x] Batch operations on multiple PRs (comment command)
-- [ ] Interactive PR selection with `fzf` integration for conflict resolution
-- [ ] Support for PR templates in multiple formats (YAML, JSON)
-- [ ] Custom cache TTL configuration
-- [ ] Integration with review tools
-- [ ] Template validation and linting