Commit 8e3de494e11f
Changed files (6)
tools
gh-pr
cmd
internal
conflicts
templates
tools/gh-pr/cmd/gh-pr/list_templates.go
@@ -15,14 +15,24 @@ func listTemplatesCmd(out *output.Writer) *cobra.Command {
)
cmd := &cobra.Command{
- Use: "list-templates",
+ 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.`,
+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 {
- return runListTemplates(out, refresh, verbose)
+ var repo string
+ if len(args) > 0 {
+ repo = args[0]
+ }
+ return runListTemplates(out, repo, refresh, verbose)
},
}
@@ -32,23 +42,44 @@ the cache and search for templates again.`,
return cmd
}
-func runListTemplates(out *output.Writer, refresh, verbose bool) error {
+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)
}
- if refresh {
- out.Info("Refreshing template cache...")
- }
+ var tmplList []templates.Template
- tmplList, err := finder.Find(refresh)
- if err != nil {
- return fmt.Errorf("failed to find templates: %w", err)
+ 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 {
- out.Warning("No pull request templates found.")
+ 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")
@@ -57,7 +88,11 @@ func runListTemplates(out *output.Writer, refresh, verbose bool) error {
return nil
}
- out.Success("Found %d pull request template(s):", len(tmplList))
+ 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 {
tools/gh-pr/cmd/gh-pr/resolve_conflicts.go
@@ -2,8 +2,10 @@ package main
import (
"fmt"
+ "strings"
"github.com/spf13/cobra"
+ "github.com/vdemeester/home/tools/gh-pr/internal/conflicts"
"github.com/vdemeester/home/tools/gh-pr/internal/output"
)
@@ -21,26 +23,42 @@ func resolveConflictsCmd(out *output.Writer) *cobra.Command {
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.
+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 # Interactive mode
+ 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 -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 {
- // Parse repository argument
+ var repo, prNumber string
+
if len(args) > 0 {
- // TODO: Implement full functionality
- // For now, this is a placeholder
+ arg := args[0]
+ if strings.Contains(arg, "#") {
+ parts := strings.SplitN(arg, "#", 2)
+ repo = parts[0]
+ prNumber = parts[1]
+ } else {
+ repo = arg
+ }
}
- 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")
+ return runResolveConflicts(out, resolveConflictsOpts{
+ worktreeDir: worktreeDir,
+ useWorktree: !noWorktree,
+ autoPush: !noPush,
+ org: org,
+ author: author,
+ repo: repo,
+ prNumber: prNumber,
+ })
},
}
@@ -53,10 +71,80 @@ Examples:
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
+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.Warning("Found %d pull request(s) with merge conflicts:", len(prs))
+ out.Println("")
+
+ // Display PRs
+ for i, pr := range prs {
+ out.Println("%d. PR #%d: %s (@%s)", i+1, pr.Number, pr.Title, pr.Author.Login)
+ }
+
+ out.Println("")
+ out.Info("Processing conflicting pull requests...")
+ out.Println("")
+
+ // Resolve each PR
+ for _, pr := range prs {
+ // Determine repository from PR
+ repo := fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
+ if pr.IsCrossRepository {
+ // For cross-repo PRs, we need the base repo
+ // This is a limitation - we'd need to track base repo in search results
+ out.Warning("Skipping cross-repository PR #%d (requires base repo info)", pr.Number)
+ continue
+ }
+
+ if err := resolver.ResolvePR(repo, &pr); err != nil {
+ out.Error("Failed to resolve PR #%d: %v", pr.Number, err)
+ out.Println("")
+ continue
+ }
+
+ out.Success("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ out.Println("")
+ }
+
+ out.Success("Done!")
+ return nil
+}
tools/gh-pr/internal/conflicts/conflicts.go
@@ -0,0 +1,467 @@
+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/templates/templates.go
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"fmt"
"os"
+ "os/exec"
"path/filepath"
"strings"
"time"
@@ -35,7 +36,7 @@ func NewFinder() (*Finder, error) {
}, nil
}
-// Find locates all PR templates in the repository
+// 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
@@ -55,7 +56,7 @@ func (f *Finder) Find(refresh bool) ([]Template, error) {
}
// Search for templates
- templates, err := f.searchTemplates()
+ templates, err := f.searchTemplates(".")
if err != nil {
return nil, err
}
@@ -69,6 +70,54 @@ func (f *Finder) Find(refresh bool) ([]Template, error) {
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()
@@ -82,8 +131,8 @@ func (f *Finder) generateCacheKey(dir string) string {
return fmt.Sprintf("templates-%x", h.Sum(nil))
}
-// searchTemplates performs the actual search for PR templates
-func (f *Finder) searchTemplates() ([]Template, error) {
+// 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
@@ -96,14 +145,15 @@ func (f *Finder) searchTemplates() ([]Template, error) {
}
for _, loc := range locations {
- info, err := os.Stat(loc)
+ 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(loc)
+ entries, err := os.ReadDir(fullPath)
if err != nil {
continue
}
@@ -118,20 +168,22 @@ func (f *Finder) searchTemplates() ([]Template, error) {
continue
}
- path := filepath.Join(loc, name)
+ 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: path,
+ Path: relPath,
Name: strings.TrimSuffix(name, ".md"),
Content: string(content),
})
}
} else {
- content, err := os.ReadFile(loc)
+ content, err := os.ReadFile(fullPath)
if err != nil {
continue
}
tools/gh-pr/default.nix
@@ -10,7 +10,7 @@ buildGoModule {
version = "0.1.0";
src = ./.;
- vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
+ vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k="; # No external dependencies
nativeBuildInputs = [ makeWrapper ];
tools/gh-pr/README.md
@@ -5,9 +5,10 @@ A comprehensive GitHub Pull Request management tool written in Go, consolidating
## Features
- **PR Creation with Templates**: Create pull requests with automatic template discovery and caching
-- **Template Management**: List and preview available PR templates
+- **Template Management**: List and preview templates from local or remote repositories
+- **Remote Template Discovery**: Browse templates from any GitHub repository
- **Workflow Restart**: Automatically restart failed GitHub Actions workflows
-- **Conflict Resolution**: (Placeholder) Will support interactive merge conflict resolution
+- **Conflict Resolution**: Interactive merge conflict resolution with worktree support
- **Template Caching**: Templates are cached for one week to speed up operations
## Installation
@@ -73,23 +74,43 @@ Templates are automatically discovered from:
### `gh-pr list-templates`
-List all available PR templates in the repository.
+List all available PR templates in the current or a remote repository.
```bash
-# List templates
+# 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.
@@ -125,16 +146,62 @@ gh-pr restart-failed owner/repo
### `gh-pr resolve-conflicts`
-Resolve merge conflicts in pull requests (placeholder - not yet implemented).
+Resolve merge conflicts in pull requests interactively with full worktree support.
```bash
-# This command is not yet fully implemented
+# Search for your conflicting PRs
gh-pr resolve-conflicts
-# For now, use the existing shell script:
-gh-resolve-conflicts
+# Resolve a specific PR
+gh-pr resolve-conflicts owner/repo#123
+
+# Filter by organization
+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. **Setup Worktree**: Creates an isolated worktree for each PR (or uses existing repo)
+3. **Fetch Branches**: Fetches both the PR branch and upstream base branch
+4. **Rebase**: Attempts to rebase the PR onto the base branch
+5. **Resolve Conflicts**: Launches conflict resolution tool:
+ - Tries `emacs` with ediff mode first
+ - Falls back to `git mergetool` if emacs is unavailable
+6. **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
+
## Template Caching
Templates are cached for **7 days** (one week) by default. This significantly speeds up operations when working with the same repository.
@@ -203,12 +270,12 @@ gh-pr restart-failed --ignore "e2e-tests"
## Integration with Existing Tools
-This tool is designed to consolidate and replace:
+This tool consolidates and replaces:
-- `gh-restart-failed`: Shell script for restarting failed workflows
-- `gh-resolve-conflicts`: Shell script for resolving merge conflicts (not yet migrated)
+- `gh-restart-failed`: Now integrated as `gh-pr restart-failed`
+- `gh-resolve-conflicts`: Now integrated as `gh-pr resolve-conflicts`
-The old tools remain available during the transition period.
+The old shell scripts are now deprecated in favor of this unified Go tool.
## Development
@@ -238,9 +305,11 @@ MIT
## Future Enhancements
-- [ ] Full implementation of `resolve-conflicts` command
-- [ ] Interactive PR selection with `fzf` integration
+- [x] Full implementation of `resolve-conflicts` command
+- [x] Remote repository template discovery
+- [ ] Interactive PR selection with `fzf` integration for conflict resolution
- [ ] Support for PR templates in multiple formats (YAML, JSON)
- [ ] Batch operations on multiple PRs
- [ ] Custom cache TTL configuration
- [ ] Integration with review tools
+- [ ] Template validation and linting