Commit 35783eca45ef
Changed files (14)
pkgs
tools
gh-pr
internal
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