Commit 35783eca45ef

Vincent Demeester <vincent@sbr.pm>
2025-12-12 10:41:43
feat(tools): Add gh-pr tool for streamlined PR management
- Speed up PR creation from Claude Code with template discovery - Reduce repeated searches with 7-day template caching - Consolidate GitHub workflow tools in single Go-based CLI Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9b68bd9
pkgs/default.nix
@@ -21,6 +21,7 @@ in
   govanityurl = pkgs.callPackage ./govanityurl { };
   batzconverter = pkgs.callPackage ./batzconverter { };
   manifest-tool = pkgs.callPackage ./manifest-tool { };
+  gh-pr = pkgs.callPackage ../tools/gh-pr { };
   gh-restart-failed = pkgs.callPackage ../tools/gh-restart-failed { };
   gh-resolve-conflicts = pkgs.callPackage ../tools/gh-resolve-conflicts { };
   arr = pkgs.callPackage ../tools/arr { };
tools/gh-pr/cmd/gh-pr/create.go
@@ -0,0 +1,182 @@
+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
@@ -0,0 +1,113 @@
+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",
+		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.`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runListTemplates(out, 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, refresh, verbose bool) error {
+	finder, err := templates.NewFinder()
+	if err != nil {
+		return fmt.Errorf("failed to create template finder: %w", err)
+	}
+
+	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 {
+		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
+	}
+
+	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
@@ -0,0 +1,51 @@
+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))
+
+	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
@@ -0,0 +1,62 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"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 is currently a placeholder that will delegate to the existing
+gh-resolve-conflicts script. Full Go implementation coming soon.
+
+Examples:
+  gh-pr resolve-conflicts                 # Interactive mode
+  gh-pr resolve-conflicts owner/repo#123  # Resolve specific PR
+  gh-pr resolve-conflicts -o tektoncd     # Filter by organization`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// Parse repository argument
+			if len(args) > 0 {
+				// TODO: Implement full functionality
+				// For now, this is a placeholder
+			}
+
+			out.Warning("The resolve-conflicts command is not yet fully implemented in Go.")
+			out.Info("Please use the existing gh-resolve-conflicts tool for now.")
+			out.Println("")
+			out.Info("Usage: gh-resolve-conflicts [options] [repository]")
+
+			return fmt.Errorf("not implemented: use gh-resolve-conflicts instead")
+		},
+	}
+
+	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
+}
+
+// TODO: Implement full conflict resolution in Go
+// This would include:
+// - Finding PRs with merge conflicts
+// - Creating worktrees or using existing repo
+// - Performing rebase
+// - Launching merge conflict resolution tools
+// - Force-pushing resolved changes
tools/gh-pr/cmd/gh-pr/restart_failed.go
@@ -0,0 +1,291 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"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 {
+	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
+	failedPRs := []prInfo{}
+	for _, pr := range prs {
+		hasFailed := false
+		for _, check := range pr.StatusCheckRollup {
+			if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" ||
+				check.Conclusion == "STARTUP_FAILURE" || check.Conclusion == "ACTION_REQUIRED" {
+				hasFailed = true
+				break
+			}
+		}
+		if hasFailed {
+			failedPRs = append(failedPRs, pr)
+		}
+	}
+
+	if len(failedPRs) == 0 {
+		out.Success("No pull requests with failed checks found!")
+		return nil
+	}
+
+	out.Warning("Found %d pull request(s) with failed checks:", len(failedPRs))
+	out.Println("")
+
+	// Display PRs for user
+	for i, pr := range failedPRs {
+		failedCount := 0
+		for _, check := range pr.StatusCheckRollup {
+			if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" ||
+				check.Conclusion == "STARTUP_FAILURE" || check.Conclusion == "ACTION_REQUIRED" {
+				failedCount++
+			}
+		}
+
+		author := "unknown"
+		if login, ok := pr.Author["login"].(string); ok {
+			author = login
+		}
+
+		out.Println("%d. PR #%d: %s (@%s) - %d failed", i+1, pr.Number, pr.Title, author, failedCount)
+	}
+
+	out.Println("")
+	out.Info("Processing all PRs with failed workflows...")
+	out.Println("")
+
+	// Restart workflows for each PR
+	for _, pr := range failedPRs {
+		out.Info("PR #%d: %s", pr.Number, pr.Title)
+		if err := restartPRWorkflows(out, opts, pr.Number, 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
@@ -0,0 +1,129 @@
+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
@@ -0,0 +1,117 @@
+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/output/output.go
@@ -0,0 +1,80 @@
+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
@@ -0,0 +1,164 @@
+package templates
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"os"
+	"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 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
+}
+
+// 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
+func (f *Finder) searchTemplates() ([]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 {
+		info, err := os.Stat(loc)
+		if err != nil {
+			continue
+		}
+
+		if info.IsDir() {
+			// List all markdown files in the directory
+			entries, err := os.ReadDir(loc)
+			if err != nil {
+				continue
+			}
+
+			for _, entry := range entries {
+				if entry.IsDir() {
+					continue
+				}
+
+				name := entry.Name()
+				if !strings.HasSuffix(name, ".md") {
+					continue
+				}
+
+				path := filepath.Join(loc, name)
+				content, err := os.ReadFile(path)
+				if err != nil {
+					continue
+				}
+
+				templates = append(templates, Template{
+					Path:    path,
+					Name:    strings.TrimSuffix(name, ".md"),
+					Content: string(content),
+				})
+			}
+		} else {
+			content, err := os.ReadFile(loc)
+			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/default.nix
@@ -0,0 +1,32 @@
+{
+  buildGoModule,
+  lib,
+  makeWrapper,
+  gh,
+}:
+
+buildGoModule {
+  pname = "gh-pr";
+  version = "0.1.0";
+  src = ./.;
+
+  vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  # Build all binaries
+  subPackages = [ "cmd/gh-pr" ];
+
+  # Wrap binary to include gh in PATH
+  postInstall = ''
+    wrapProgram $out/bin/gh-pr \
+      --prefix PATH : ${lib.makeBinPath [ gh ]}
+  '';
+
+  meta = {
+    description = "GitHub Pull Request management tool with template support, workflow restart, and conflict resolution";
+    license = lib.licenses.mit;
+    platforms = lib.platforms.unix;
+    mainProgram = "gh-pr";
+  };
+}
tools/gh-pr/go.mod
@@ -0,0 +1,10 @@
+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
@@ -0,0 +1,10 @@
+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
@@ -0,0 +1,246 @@
+# 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 available PR templates
+- **Workflow Restart**: Automatically restart failed GitHub Actions workflows
+- **Conflict Resolution**: (Placeholder) Will support interactive merge conflict resolution
+- **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 list-templates`
+
+List all available PR templates in the repository.
+
+```bash
+# List templates
+gh-pr list-templates
+
+# Show template content preview
+gh-pr list-templates --verbose
+
+# Refresh cache and list templates
+gh-pr list-templates --refresh
+```
+
+**Options:**
+- `--refresh`: Refresh template cache
+- `-v, --verbose`: Show template content preview
+
+### `gh-pr restart-failed`
+
+Restart failed workflow runs on pull requests.
+
+```bash
+# Interactive mode - list all PRs with failed checks
+gh-pr restart-failed
+
+# Restart workflows for a specific PR
+gh-pr restart-failed owner/repo#123
+
+# Filter by label
+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)
+
+**Default Behavior:**
+- "Label Checker" workflows are ignored by default
+- Only restarts workflows that failed due to:
+  - `failure`
+  - `timed_out`
+  - `startup_failure`
+  - `action_required`
+
+### `gh-pr resolve-conflicts`
+
+Resolve merge conflicts in pull requests (placeholder - not yet implemented).
+
+```bash
+# This command is not yet fully implemented
+gh-pr resolve-conflicts
+
+# For now, use the existing shell script:
+gh-resolve-conflicts
+```
+
+## 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 (stub)
+├── 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
+```
+
+### Restarting Failed Workflows
+
+```bash
+# See all PRs with failures and restart them
+gh-pr restart-failed
+
+# Restart specific PR in another repo
+gh-pr restart-failed tektoncd/pipeline#1234
+
+# Ignore flaky tests
+gh-pr restart-failed --ignore "e2e-tests"
+```
+
+## Integration with Existing Tools
+
+This tool is designed to consolidate and replace:
+
+- `gh-restart-failed`: Shell script for restarting failed workflows
+- `gh-resolve-conflicts`: Shell script for resolving merge conflicts (not yet migrated)
+
+The old tools remain available during the transition period.
+
+## 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
+- `jq` - Used for JSON parsing in workflow operations
+- Go 1.23+ - For building from source
+
+## License
+
+MIT
+
+## Future Enhancements
+
+- [ ] Full implementation of `resolve-conflicts` command
+- [ ] Interactive PR selection with `fzf` integration
+- [ ] Support for PR templates in multiple formats (YAML, JSON)
+- [ ] Batch operations on multiple PRs
+- [ ] Custom cache TTL configuration
+- [ ] Integration with review tools