Commit 5abd6348ee41
Changed files (13)
pkgs
systems
kyushu
tools
gh-pr
gh-resolve-conflicts
gh-restart-failed
pkgs/default.nix
@@ -22,8 +22,6 @@ in
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 { };
download-kiwix-zim = pkgs.callPackage ../tools/download-kiwix-zim { };
cliphist-cleanup = pkgs.callPackage ../tools/cliphist-cleanup { };
systems/kyushu/home.nix
@@ -56,8 +56,7 @@
sbcl
go-org-readwise
- gh-restart-failed
- gh-resolve-conflicts
+ gh-pr
arr
claude-hooks
toggle-color-scheme
tools/gh-pr/cmd/gh-pr/comment.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/vdemeester/home/tools/gh-pr/internal/output"
+)
+
+func commentCmd(out *output.Writer) *cobra.Command {
+ var (
+ body string
+ repo string
+ labels []string
+ author string
+ state string
+ )
+
+ cmd := &cobra.Command{
+ Use: "comment",
+ Short: "Comment on multiple pull requests",
+ Long: `Select and comment on multiple pull requests using fzf.
+
+This command lists pull requests and allows you to select multiple PRs
+using fzf's multi-select feature. You can then add the same comment to
+all selected PRs.
+
+Examples:
+ gh-pr comment # Select PRs interactively
+ gh-pr comment --body "LGTM" # Pre-specify the comment
+ gh-pr comment --label bug # Filter by label
+ gh-pr comment --repo owner/repo # Work with a specific repo
+ gh-pr comment --state all # Include closed PRs`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runComment(out, commentOpts{
+ body: body,
+ repo: repo,
+ labels: labels,
+ author: author,
+ state: state,
+ })
+ },
+ }
+
+ cmd.Flags().StringVarP(&body, "body", "b", "", "Comment body (will prompt if not provided)")
+ cmd.Flags().StringVarP(&repo, "repo", "R", "", "Repository (owner/repo format)")
+ cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Filter PRs by label (comma-separated)")
+ cmd.Flags().StringVarP(&author, "author", "a", "", "Filter PRs by author")
+ cmd.Flags().StringVarP(&state, "state", "s", "open", "Filter by state: open, closed, merged, all")
+
+ return cmd
+}
+
+type commentOpts struct {
+ body string
+ repo string
+ labels []string
+ author string
+ state string
+}
+
+func runComment(out *output.Writer, opts commentOpts) error {
+ // Check if fzf is available
+ if _, err := exec.LookPath("fzf"); err != nil {
+ return fmt.Errorf("fzf is required but not found in PATH: %w", err)
+ }
+
+ // Build gh pr list command
+ ghArgs := []string{"pr", "list"}
+
+ if opts.repo != "" {
+ ghArgs = append(ghArgs, "--repo", opts.repo)
+ }
+
+ for _, label := range opts.labels {
+ ghArgs = append(ghArgs, "--label", label)
+ }
+
+ if opts.author != "" {
+ ghArgs = append(ghArgs, "--author", opts.author)
+ }
+
+ if opts.state != "" {
+ ghArgs = append(ghArgs, "--state", opts.state)
+ }
+
+ // Get list of PRs
+ out.Info("Fetching pull requests...")
+ ghCmd := exec.Command("gh", ghArgs...)
+ ghOutput, err := ghCmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ return fmt.Errorf("gh pr list failed: %s", exitErr.Stderr)
+ }
+ return fmt.Errorf("gh pr list failed: %w", err)
+ }
+
+ prList := strings.TrimSpace(string(ghOutput))
+ if prList == "" {
+ out.Warning("No pull requests found matching the criteria.")
+ return nil
+ }
+
+ // Build preview command for fzf
+ repoFlag := ""
+ if opts.repo != "" {
+ repoFlag = fmt.Sprintf("-R %s", opts.repo)
+ }
+
+ previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
+ jq -r '"# PR " + (.number | tostring) + ": " + .title,
+ "",
+ "Author: @" + .author.login,
+ "",
+ "## Status Checks:",
+ "",
+ (if (.statusCheckRollup // [] | length) == 0 then " (No status checks)"
+ else (.statusCheckRollup | map(
+ " " + (
+ if .conclusion == "SUCCESS" then "✓"
+ elif .conclusion == "FAILURE" then "✗"
+ elif .conclusion == "PENDING" then "●"
+ elif .conclusion == "SKIPPED" then "○"
+ else "?"
+ end
+ ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
+ ) | join("\n"))
+ end)'`, repoFlag)
+
+ // Use fzf for multi-select with preview
+ out.Info("Select pull requests (use Tab to select multiple, Enter to confirm)...")
+ fzfCmd := exec.Command("fzf",
+ "--multi",
+ "--ansi",
+ "--header", "Select PRs (Tab: select, Enter: confirm)",
+ "--preview", previewCmd,
+ "--preview-window", "right:60%:wrap",
+ )
+ fzfCmd.Stdin = strings.NewReader(prList)
+ fzfCmd.Stderr = os.Stderr
+
+ selectedOutput, err := fzfCmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
+ // User cancelled with Ctrl+C
+ out.Info("Selection cancelled.")
+ return nil
+ }
+ return fmt.Errorf("fzf selection failed: %w", err)
+ }
+
+ selectedPRs := strings.TrimSpace(string(selectedOutput))
+ if selectedPRs == "" {
+ out.Info("No pull requests selected.")
+ return nil
+ }
+
+ // Extract PR numbers from selected lines
+ prNumbers := []string{}
+ scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Extract PR number from the first field (format: "123 title...")
+ fields := strings.Fields(line)
+ if len(fields) > 0 {
+ prNumbers = append(prNumbers, fields[0])
+ }
+ }
+
+ if len(prNumbers) == 0 {
+ out.Warning("No valid PR numbers found in selection.")
+ return nil
+ }
+
+ out.Success("Selected %d pull request(s): %s", len(prNumbers), strings.Join(prNumbers, ", "))
+
+ // Get comment body if not provided
+ commentBody := opts.body
+ if commentBody == "" {
+ out.Info("Enter your comment (Ctrl+D when done):")
+ var buf bytes.Buffer
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ buf.WriteString(scanner.Text())
+ buf.WriteString("\n")
+ }
+ if err := scanner.Err(); err != nil {
+ return fmt.Errorf("failed to read comment: %w", err)
+ }
+ commentBody = strings.TrimSpace(buf.String())
+ }
+
+ if commentBody == "" {
+ out.Warning("Empty comment body. No comments will be posted.")
+ return nil
+ }
+
+ // Comment on each PR
+ out.Info("Posting comment to %d pull request(s)...", len(prNumbers))
+ failed := []string{}
+
+ for _, prNum := range prNumbers {
+ commentArgs := []string{"pr", "comment", prNum, "--body", commentBody}
+ if opts.repo != "" {
+ commentArgs = append(commentArgs, "--repo", opts.repo)
+ }
+
+ commentCmd := exec.Command("gh", commentArgs...)
+ if err := commentCmd.Run(); err != nil {
+ out.Error("Failed to comment on PR #%s: %v", prNum, err)
+ failed = append(failed, prNum)
+ } else {
+ out.Success("Commented on PR #%s", prNum)
+ }
+ }
+
+ // Summary
+ successCount := len(prNumbers) - len(failed)
+ if successCount > 0 {
+ out.Success("\nSuccessfully commented on %d pull request(s)", successCount)
+ }
+ if len(failed) > 0 {
+ out.Error("Failed to comment on %d pull request(s): %s", len(failed), strings.Join(failed, ", "))
+ return fmt.Errorf("some comments failed")
+ }
+
+ return nil
+}
tools/gh-pr/cmd/gh-pr/main.go
@@ -36,6 +36,7 @@ and conflict resolution in a single command-line interface.`,
cmd.AddCommand(listTemplatesCmd(out))
cmd.AddCommand(restartFailedCmd(out))
cmd.AddCommand(resolveConflictsCmd(out))
+ cmd.AddCommand(commentCmd(out))
return cmd
}
tools/gh-pr/cmd/gh-pr/resolve_conflicts.go
@@ -1,7 +1,10 @@
package main
import (
+ "bufio"
"fmt"
+ "os"
+ "os/exec"
"strings"
"github.com/spf13/cobra"
@@ -112,31 +115,122 @@ func runResolveConflicts(out *output.Writer, opts resolveConflictsOpts) error {
return nil
}
- out.Warning("Found %d pull request(s) with merge conflicts:", len(prs))
- out.Println("")
+ out.Success("Found %d pull request(s) with merge conflicts", len(prs))
- // Display PRs
- for i, pr := range prs {
- out.Println("%d. PR #%d: %s (@%s)", i+1, pr.Number, pr.Title, pr.Author.Login)
+ // Check if fzf is available
+ if _, err := exec.LookPath("fzf"); err != nil {
+ return fmt.Errorf("fzf is required but not found in PATH: %w", err)
}
- out.Println("")
- out.Info("Processing conflicting pull requests...")
+ // Build fzf input with formatted PR information
+ var fzfInput strings.Builder
+ prMap := make(map[int]*conflicts.PRInfo) // Map PR number to info for later lookup
+
+ for _, pr := range prs {
+ prMap[pr.Number] = &pr
+ // Format: "#123 Title here (@author) [repo]"
+ repo := pr.HeadRepository.NameWithOwner
+ fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) [%s]\n",
+ pr.Number, pr.Title, pr.Author.Login, repo))
+ }
+
+ // Build preview command for fzf
+ // We need to extract the repo from the selection and fetch PR details
+ previewCmd := `echo {} | awk '{print $1, $(NF)}' | sed 's/\[//;s/\]//' | \
+ xargs -I{} sh -c 'PR=$(echo {} | cut -d" " -f1); REPO=$(echo {} | cut -d" " -f2); \
+ gh pr view $PR -R $REPO --json number,title,author,headRefName,baseRefName,mergeable,statusCheckRollup 2>/dev/null | \
+ jq -r "\"# PR \" + (.number | tostring) + \": \" + .title,
+ \"\",
+ \"Author: @\" + .author.login,
+ \"\",
+ \"## Branches:\",
+ \"\",
+ \" \" + .headRefName + \" → \" + .baseRefName,
+ \"\",
+ \"## Merge Status: \" + (.mergeable // \"unknown\"),
+ \"\",
+ \"## Status Checks:\",
+ \"\",
+ (if (.statusCheckRollup // [] | length) == 0 then \" (No status checks)\"
+ else (.statusCheckRollup | map(
+ \" \" + (
+ if .conclusion == \"SUCCESS\" then \"✓\"
+ elif .conclusion == \"FAILURE\" then \"✗\"
+ elif .conclusion == \"PENDING\" then \"●\"
+ elif .conclusion == \"SKIPPED\" then \"○\"
+ else \"?\"
+ end
+ ) + \" \" + .name + \" (\" + (.conclusion // \"unknown\") + \")\"
+ ) | join(\"\\n\"))
+ end)"'`
+
+ // Use fzf for multi-select with preview
+ out.Info("Select pull requests to resolve (use Tab to select multiple, Enter to confirm)...")
+ fzfCmd := exec.Command("fzf",
+ "--multi",
+ "--ansi",
+ "--header", "Select PRs to resolve conflicts (Tab: select, Enter: confirm)",
+ "--preview", previewCmd,
+ "--preview-window", "right:60%:wrap",
+ )
+ fzfCmd.Stdin = strings.NewReader(fzfInput.String())
+ fzfCmd.Stderr = os.Stderr
+
+ selectedOutput, err := fzfCmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
+ // User cancelled with Ctrl+C
+ out.Info("Selection cancelled.")
+ return nil
+ }
+ return fmt.Errorf("fzf selection failed: %w", err)
+ }
+
+ selectedPRs := strings.TrimSpace(string(selectedOutput))
+ if selectedPRs == "" {
+ out.Info("No pull requests selected.")
+ return nil
+ }
+
+ // Extract PR numbers from selected lines
+ selectedPRNumbers := []int{}
+ scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Extract PR number from format "#123 Title..."
+ if strings.HasPrefix(line, "#") {
+ var prNum int
+ if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
+ selectedPRNumbers = append(selectedPRNumbers, prNum)
+ }
+ }
+ }
+
+ if len(selectedPRNumbers) == 0 {
+ out.Warning("No valid PR numbers found in selection.")
+ return nil
+ }
+
+ out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
out.Println("")
- // Resolve 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)
+ // Resolve selected PRs
+ for _, prNum := range selectedPRNumbers {
+ pr, ok := prMap[prNum]
+ if !ok {
+ out.Warning("PR #%d not found in the list, skipping...", prNum)
continue
}
- if err := resolver.ResolvePR(repo, &pr); err != nil {
- out.Error("Failed to resolve PR #%d: %v", pr.Number, err)
+ // Determine repository from PR
+ repo := pr.HeadRepository.NameWithOwner
+ if pr.IsCrossRepository {
+ out.Warning("Skipping cross-repository PR #%d (requires manual handling)", prNum)
+ continue
+ }
+
+ if err := resolver.ResolvePR(repo, pr); err != nil {
+ out.Error("Failed to resolve PR #%d: %v", prNum, err)
out.Println("")
continue
}
tools/gh-pr/cmd/gh-pr/restart_failed.go
@@ -1,8 +1,10 @@
package main
import (
+ "bufio"
"encoding/json"
"fmt"
+ "os"
"os/exec"
"strings"
@@ -128,6 +130,11 @@ func restartSpecificPR(out *output.Writer, opts restartFailedOpts) error {
}
func restartInteractive(out *output.Writer, opts restartFailedOpts) error {
+ // Check if fzf is available
+ if _, err := exec.LookPath("fzf"); err != nil {
+ return fmt.Errorf("fzf is required but not found in PATH: %w", err)
+ }
+
out.Info("Fetching pull requests...")
// Build gh pr list command
@@ -152,18 +159,31 @@ func restartInteractive(out *output.Writer, opts restartFailedOpts) error {
}
// Filter PRs with failed checks
- failedPRs := []prInfo{}
+ type failedPRInfo struct {
+ pr prInfo
+ failedCount int
+ author string
+ }
+
+ failedPRs := []failedPRInfo{}
for _, pr := range prs {
- hasFailed := false
+ failedCount := 0
for _, check := range pr.StatusCheckRollup {
if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" ||
check.Conclusion == "STARTUP_FAILURE" || check.Conclusion == "ACTION_REQUIRED" {
- hasFailed = true
- break
+ failedCount++
}
}
- if hasFailed {
- failedPRs = append(failedPRs, pr)
+ if failedCount > 0 {
+ author := "unknown"
+ if login, ok := pr.Author["login"].(string); ok {
+ author = login
+ }
+ failedPRs = append(failedPRs, failedPRInfo{
+ pr: pr,
+ failedCount: failedCount,
+ author: author,
+ })
}
}
@@ -172,35 +192,103 @@ func restartInteractive(out *output.Writer, opts restartFailedOpts) error {
return nil
}
- out.Warning("Found %d pull request(s) with failed checks:", len(failedPRs))
- out.Println("")
+ out.Success("Found %d pull request(s) with failed checks", len(failedPRs))
- // 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++
- }
- }
+ // Build fzf input with formatted PR information
+ var fzfInput strings.Builder
+ prMap := make(map[int]failedPRInfo) // Map PR number to info for later lookup
- 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)
+ for _, fpr := range failedPRs {
+ prMap[fpr.pr.Number] = fpr
+ // Format: "#123 Title here (@author) - 2 failed"
+ fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) - %d failed\n",
+ fpr.pr.Number, fpr.pr.Title, fpr.author, fpr.failedCount))
}
- out.Println("")
- out.Info("Processing all PRs with failed workflows...")
+ // Build preview command for fzf
+ repoFlag := ""
+ if opts.repo != "" {
+ repoFlag = fmt.Sprintf("-R %s", opts.repo)
+ }
+
+ previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
+ jq -r '"# PR " + (.number | tostring) + ": " + .title,
+ "",
+ "Author: @" + .author.login,
+ "",
+ "## Status Checks:",
+ "",
+ (.statusCheckRollup // [] | map(
+ " " + (
+ if .conclusion == "SUCCESS" then "✓"
+ elif .conclusion == "FAILURE" then "✗"
+ elif .conclusion == "PENDING" then "●"
+ elif .conclusion == "SKIPPED" then "○"
+ else "?"
+ end
+ ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
+ ) | join("\n"))'`, repoFlag)
+
+ // Use fzf for multi-select with preview
+ out.Info("Select pull requests to restart (use Tab to select multiple, Enter to confirm)...")
+ fzfCmd := exec.Command("fzf",
+ "--multi",
+ "--ansi",
+ "--header", "Select PRs to restart workflows (Tab: select, Enter: confirm)",
+ "--preview", previewCmd,
+ "--preview-window", "right:60%:wrap",
+ )
+ fzfCmd.Stdin = strings.NewReader(fzfInput.String())
+ fzfCmd.Stderr = os.Stderr
+
+ selectedOutput, err := fzfCmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
+ // User cancelled with Ctrl+C
+ out.Info("Selection cancelled.")
+ return nil
+ }
+ return fmt.Errorf("fzf selection failed: %w", err)
+ }
+
+ selectedPRs := strings.TrimSpace(string(selectedOutput))
+ if selectedPRs == "" {
+ out.Info("No pull requests selected.")
+ return nil
+ }
+
+ // Extract PR numbers from selected lines
+ selectedPRNumbers := []int{}
+ scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Extract PR number from format "#123 Title..."
+ if strings.HasPrefix(line, "#") {
+ var prNum int
+ if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
+ selectedPRNumbers = append(selectedPRNumbers, prNum)
+ }
+ }
+ }
+
+ if len(selectedPRNumbers) == 0 {
+ out.Warning("No valid PR numbers found in selection.")
+ return nil
+ }
+
+ out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
out.Println("")
- // Restart workflows for 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 {
+ // Restart workflows for selected PRs
+ for _, prNum := range selectedPRNumbers {
+ fpr, ok := prMap[prNum]
+ if !ok {
+ out.Warning("PR #%d not found in the list, skipping...", prNum)
+ continue
+ }
+
+ out.Info("PR #%d: %s", fpr.pr.Number, fpr.pr.Title)
+ if err := restartPRWorkflows(out, opts, fpr.pr.Number, fpr.pr.HeadRefName); err != nil {
out.Error("Failed to restart workflows: %v", err)
}
out.Println("")
tools/gh-pr/default.nix
@@ -2,7 +2,10 @@
buildGoModule,
lib,
makeWrapper,
+ installShellFiles,
gh,
+ fzf,
+ jq,
}:
buildGoModule {
@@ -12,19 +15,34 @@ buildGoModule {
vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k="; # No external dependencies
- nativeBuildInputs = [ makeWrapper ];
+ nativeBuildInputs = [
+ makeWrapper
+ installShellFiles
+ ];
# Build all binaries
subPackages = [ "cmd/gh-pr" ];
- # Wrap binary to include gh in PATH
+ # Wrap binary to include gh, fzf, and jq in PATH and install completions
postInstall = ''
wrapProgram $out/bin/gh-pr \
- --prefix PATH : ${lib.makeBinPath [ gh ]}
+ --prefix PATH : ${
+ lib.makeBinPath [
+ gh
+ fzf
+ jq
+ ]
+ }
+
+ # Generate shell completions
+ installShellCompletion --cmd gh-pr \
+ --bash <($out/bin/gh-pr completion bash) \
+ --fish <($out/bin/gh-pr completion fish) \
+ --zsh <($out/bin/gh-pr completion zsh)
'';
meta = {
- description = "GitHub Pull Request management tool with template support, workflow restart, and conflict resolution";
+ description = "GitHub Pull Request management tool with template support, workflow restart, conflict resolution, and batch commenting";
license = lib.licenses.mit;
platforms = lib.platforms.unix;
mainProgram = "gh-pr";
tools/gh-pr/README.md
@@ -7,6 +7,7 @@ A comprehensive GitHub Pull Request management tool written in Go, consolidating
- **PR Creation with Templates**: Create pull requests with automatic template discovery and caching
- **Template Management**: List and preview templates from local or remote repositories
- **Remote Template Discovery**: Browse templates from any GitHub repository
+- **Batch Commenting**: Comment on multiple pull requests at once using fzf multi-select
- **Workflow Restart**: Automatically restart failed GitHub Actions workflows
- **Conflict Resolution**: Interactive merge conflict resolution with worktree support
- **Template Caching**: Templates are cached for one week to speed up operations
@@ -72,6 +73,53 @@ Templates are automatically discovered from:
- `.github/PULL_REQUEST_TEMPLATE/`
- `docs/PULL_REQUEST_TEMPLATE.md`
+### `gh-pr comment`
+
+Comment on multiple pull requests at once using interactive selection.
+
+```bash
+# Interactive mode - select PRs and enter comment
+gh-pr comment
+
+# Pre-specify the comment body
+gh-pr comment --body "LGTM! Approving this change."
+
+# Filter by label before selecting
+gh-pr comment --label bug --label urgent
+
+# Work with a specific repository
+gh-pr comment --repo owner/repo
+
+# Include closed PRs in the selection
+gh-pr comment --state all
+
+# Filter by author
+gh-pr comment --author username
+```
+
+**Options:**
+- `-b, --body`: Comment body (will prompt if not provided)
+- `-R, --repo`: Repository in "owner/repo" format
+- `-l, --label`: Filter PRs by label (can be used multiple times)
+- `-a, --author`: Filter PRs by author
+- `-s, --state`: Filter by state: open, closed, merged, all (default: open)
+
+**How It Works:**
+
+1. **List PRs**: Fetches pull requests matching your filters using `gh pr list`
+2. **Select**: Uses fzf for multi-select with preview pane showing:
+ - PR title and author
+ - CI check status with visual indicators (✓/✗/●/○)
+ - Tab to select, Enter to confirm
+3. **Comment**: Prompts for comment body if not provided via `--body`
+4. **Post**: Posts the same comment to all selected PRs
+
+**Use Cases:**
+- Notify multiple PRs about a related change
+- Request updates across multiple related PRs
+- Add acknowledgments to a batch of PRs
+- Communicate breaking changes to affected PRs
+
### `gh-pr list-templates`
List all available PR templates in the current or a remote repository.
@@ -113,16 +161,16 @@ This is especially useful for:
### `gh-pr restart-failed`
-Restart failed workflow runs on pull requests.
+Restart failed workflow runs on pull requests with interactive selection.
```bash
-# Interactive mode - list all PRs with failed checks
+# Interactive mode - select PRs with failed checks using fzf
gh-pr restart-failed
-# Restart workflows for a specific PR
+# Restart workflows for a specific PR (no selection needed)
gh-pr restart-failed owner/repo#123
-# Filter by label
+# Filter by label before selecting
gh-pr restart-failed --label bug
# Ignore specific workflows
@@ -136,6 +184,15 @@ gh-pr restart-failed owner/repo
- `-i, --ignore`: Ignore workflows matching pattern (can be used multiple times)
- `-l, --label`: Filter PRs by label (can be used multiple times)
+**How It Works:**
+
+1. **Find Failed PRs**: Fetches all PRs and filters those with failed checks
+2. **Select**: Uses fzf for multi-select with preview pane showing:
+ - All CI check statuses with visual indicators (✓/✗/●/○)
+ - Author information
+ - Tab to select, Enter to confirm
+3. **Restart**: Restarts failed workflows for all selected PRs
+
**Default Behavior:**
- "Label Checker" workflows are ignored by default
- Only restarts workflows that failed due to:
@@ -143,19 +200,20 @@ gh-pr restart-failed owner/repo
- `timed_out`
- `startup_failure`
- `action_required`
+- Shows failed count for each PR in the selection interface
### `gh-pr resolve-conflicts`
-Resolve merge conflicts in pull requests interactively with full worktree support.
+Resolve merge conflicts in pull requests interactively with full worktree support and fzf selection.
```bash
-# Search for your conflicting PRs
+# Search for your conflicting PRs and select with fzf
gh-pr resolve-conflicts
-# Resolve a specific PR
+# Resolve a specific PR (no selection)
gh-pr resolve-conflicts owner/repo#123
-# Filter by organization
+# Filter by organization before selecting
gh-pr resolve-conflicts -o tektoncd
# Use existing repo instead of creating worktree
@@ -178,13 +236,18 @@ gh-pr resolve-conflicts -w /tmp/my-worktrees
**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:
+2. **Select**: Uses fzf for multi-select with preview pane showing:
+ - Branch information (head → base)
+ - Merge status
+ - CI check status with visual indicators (✓/✗/●/○)
+ - Tab to select, Enter to confirm
+3. **Setup Worktree**: Creates an isolated worktree for each PR (or uses existing repo)
+4. **Fetch Branches**: Fetches both the PR branch and upstream base branch
+5. **Rebase**: Attempts to rebase the PR onto the base branch
+6. **Resolve Conflicts**: Launches conflict resolution tool:
- Tries `emacs` with ediff mode first
- Falls back to `git mergetool` if emacs is unavailable
-6. **Force Push**: Optionally force-pushes the resolved changes
+7. **Force Push**: Optionally force-pushes the resolved changes
**Fork Support:**
@@ -255,15 +318,31 @@ gh-pr create \
--reviewer team-lead
```
+### Commenting on Multiple PRs
+
+```bash
+# Select and comment on multiple PRs interactively
+gh-pr comment
+
+# Comment on all PRs with a specific label
+gh-pr comment --label needs-rebase --body "Please rebase on main"
+
+# Notify all your open PRs about a breaking change
+gh-pr comment --author @me --body "Note: This depends on #1234"
+```
+
### Restarting Failed Workflows
```bash
-# See all PRs with failures and restart them
+# Select PRs with failures and restart workflows
gh-pr restart-failed
-# Restart specific PR in another repo
+# Restart specific PR in another repo (no selection)
gh-pr restart-failed tektoncd/pipeline#1234
+# Filter and select PRs with specific label
+gh-pr restart-failed --label bug
+
# Ignore flaky tests
gh-pr restart-failed --ignore "e2e-tests"
```
@@ -296,7 +375,8 @@ nix build .#gh-pr
## Dependencies
- `gh` (GitHub CLI) - Required for all GitHub operations
-- `jq` - Used for JSON parsing in workflow operations
+- `fzf` - Required for interactive PR selection with preview
+- `jq` - Required for formatting preview pane and JSON parsing
- Go 1.23+ - For building from source
## License
@@ -307,9 +387,9 @@ MIT
- [x] Full implementation of `resolve-conflicts` command
- [x] Remote repository template discovery
+- [x] Batch operations on multiple PRs (comment command)
- [ ] Interactive PR selection with `fzf` integration for conflict resolution
- [ ] Support for PR templates in multiple formats (YAML, JSON)
-- [ ] Batch operations on multiple PRs
- [ ] Custom cache TTL configuration
- [ ] Integration with review tools
- [ ] Template validation and linting
tools/gh-resolve-conflicts/default.nix
@@ -1,42 +0,0 @@
-{
- stdenv,
- lib,
- makeWrapper,
- gh,
- fzf,
- jq,
- git,
- emacs,
-}:
-
-stdenv.mkDerivation {
- name = "gh-resolve-conflicts";
- pname = "gh-resolve-conflicts";
- version = "0.1.0";
-
- src = ./.;
-
- nativeBuildInputs = [ makeWrapper ];
-
- installPhase = ''
- mkdir -p $out/bin
- cp gh-resolve-conflicts.sh $out/bin/gh-resolve-conflicts
- chmod +x $out/bin/gh-resolve-conflicts
-
- wrapProgram $out/bin/gh-resolve-conflicts \
- --prefix PATH : ${
- lib.makeBinPath [
- gh
- fzf
- jq
- git
- emacs
- ]
- }
- '';
-
- meta = {
- description = "List and resolve merge conflicts in GitHub pull requests interactively";
- platforms = lib.platforms.unix;
- };
-}
tools/gh-resolve-conflicts/gh-resolve-conflicts.sh
@@ -1,623 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-# Help message
-usage() {
- cat <<EOF
-Usage: gh-resolve-conflicts [OPTIONS] [REPOSITORY[#PR_NUMBER]]
-
-List pull requests with merge conflicts and resolve them interactively.
-
-Options:
- -w, --worktree DIR Create worktrees in DIR (default: /tmp/gh-resolve-conflicts-worktrees)
- -n, --no-worktree Use existing repo (cd into it) instead of creating worktrees
- -N, --no-push Do NOT automatically force-push after resolution (default: auto-push)
- -o, --org ORG Filter PRs by organization
- -a, --author AUTHOR Filter PRs by author (default: @me)
- -h, --help Show this help message
-
-Arguments:
- REPOSITORY Optional repository in OWNER/REPO format.
- If not provided, searches across all repos (when -o is used).
- Can include #PR_NUMBER to directly resolve a specific PR.
-
-Dependencies:
- - gh (GitHub CLI)
- - fzf (fuzzy finder, for interactive mode)
- - jq (JSON processor)
- - git
- - emacs (for ediff conflict resolution)
-
-Examples:
- gh-resolve-conflicts # List all your conflicting PRs (interactive)
- gh-resolve-conflicts -o tektoncd # List conflicting PRs in tektoncd org
- gh-resolve-conflicts owner/repo#123 # Directly resolve PR #123
- gh-resolve-conflicts -N # Don't auto-push after resolution
- gh-resolve-conflicts -n # Use existing repo, no worktree
-
-Workflow:
- 1. Scans for PRs with merge conflicts
- 2. Interactive selection with fzf (multi-select supported)
- 3. For each PR:
- - Creates worktree or uses existing repo
- - Checks out PR branch
- - Attempts rebase against base branch
- - Launches emacs ediff for conflicts
- - Continues rebase after resolution
- - Optionally force-pushes
-
-EOF
- exit 0
-}
-
-# Check dependencies
-check_dependencies() {
- local missing=()
-
- for cmd in gh jq git; do
- if ! command -v "$cmd" &> /dev/null; then
- missing+=("$cmd")
- fi
- done
-
- if [ ${#missing[@]} -gt 0 ]; then
- echo -e "${RED}Error: Missing required dependencies: ${missing[*]}${NC}" >&2
- echo "Please install them and try again." >&2
- exit 1
- fi
-}
-
-# Check if emacs is available
-check_emacs() {
- if ! command -v emacs &> /dev/null; then
- echo -e "${YELLOW}Warning: emacs not found. Conflict resolution will use default git merge tool.${NC}" >&2
- return 1
- fi
- return 0
-}
-
-# Default settings
-WORKTREE_DIR="/tmp/gh-resolve-conflicts-worktrees"
-USE_WORKTREE=true
-AUTO_PUSH=true
-ORG_FILTER=""
-AUTHOR_FILTER="@me"
-REPO_ARG=""
-PR_NUMBER=""
-
-# Parse arguments
-while [[ $# -gt 0 ]]; do
- case $1 in
- -h|--help)
- usage
- ;;
- -w|--worktree)
- if [ -n "${2:-}" ]; then
- WORKTREE_DIR="$2"
- shift 2
- else
- echo -e "${RED}Error: --worktree requires a directory argument${NC}" >&2
- exit 1
- fi
- ;;
- -n|--no-worktree)
- USE_WORKTREE=false
- shift
- ;;
- -N|--no-push)
- AUTO_PUSH=false
- shift
- ;;
- -o|--org)
- if [ -n "${2:-}" ]; then
- ORG_FILTER="$2"
- shift 2
- else
- echo -e "${RED}Error: --org requires an organization argument${NC}" >&2
- exit 1
- fi
- ;;
- -a|--author)
- if [ -n "${2:-}" ]; then
- AUTHOR_FILTER="$2"
- shift 2
- else
- echo -e "${RED}Error: --author requires an author argument${NC}" >&2
- exit 1
- fi
- ;;
- -*)
- echo -e "${RED}Error: Unknown option: $1${NC}" >&2
- usage
- ;;
- *)
- REPO_ARG="$1"
- # Check if it contains #PR_NUMBER
- if [[ "$REPO_ARG" =~ ^(.+)#([0-9]+)$ ]]; then
- REPO_ARG="${BASH_REMATCH[1]}"
- PR_NUMBER="${BASH_REMATCH[2]}"
- fi
- shift
- ;;
- esac
-done
-
-check_dependencies
-
-# Check fzf only if in interactive mode (no PR_NUMBER specified)
-if [ -z "$PR_NUMBER" ] && ! command -v fzf &> /dev/null; then
- echo -e "${RED}Error: fzf is required for interactive mode${NC}" >&2
- echo "Please install fzf or specify a PR number directly (e.g., owner/repo#123)" >&2
- exit 1
-fi
-
-HAS_EMACS=false
-if check_emacs; then
- HAS_EMACS=true
-fi
-
-# Fetch conflicting PRs
-echo -e "${BLUE}Fetching pull requests with merge conflicts...${NC}" >&2
-
-# Build search query
-SEARCH_ARGS=(--author "$AUTHOR_FILTER" --state open)
-if [ -n "$ORG_FILTER" ]; then
- SEARCH_ARGS+=(--owner "$ORG_FILTER")
-fi
-
-if [ -n "$PR_NUMBER" ]; then
- # Direct PR mode
- if [ -z "$REPO_ARG" ]; then
- echo -e "${RED}Error: Repository must be specified when using #PR_NUMBER${NC}" >&2
- exit 1
- fi
-
- echo -e "${BLUE}Fetching PR #$PR_NUMBER from $REPO_ARG...${NC}" >&2
-
- pr_info=$(gh pr view "$PR_NUMBER" -R "$REPO_ARG" \
- --json number,title,headRefName,baseRefName,author,mergeable,url \
- 2>/dev/null)
-
- if [ -z "$pr_info" ]; then
- echo -e "${RED}Error: PR #$PR_NUMBER not found${NC}" >&2
- exit 1
- fi
-
- mergeable=$(echo "$pr_info" | jq -r '.mergeable')
- if [ "$mergeable" != "CONFLICTING" ]; then
- echo -e "${YELLOW}PR #$PR_NUMBER does not have merge conflicts (status: $mergeable)${NC}"
- exit 0
- fi
-
- pr_title=$(echo "$pr_info" | jq -r '.title')
- pr_branch=$(echo "$pr_info" | jq -r '.headRefName')
- base_branch=$(echo "$pr_info" | jq -r '.baseRefName')
- pr_author=$(echo "$pr_info" | jq -r '.author.login')
- pr_url=$(echo "$pr_info" | jq -r '.url')
-
- selected_prs="$REPO_ARG|#$PR_NUMBER|$pr_title|@$pr_author|$pr_branch|$base_branch|$pr_url"
-else
- # Interactive mode: Search for conflicting PRs
- prs_json=$(gh search prs "${SEARCH_ARGS[@]}" \
- --json number,title,repository,url \
- --limit 100)
-
- if [ -z "$prs_json" ] || [ "$prs_json" = "[]" ]; then
- echo -e "${YELLOW}No open pull requests found.${NC}"
- exit 0
- fi
-
- # Check each PR for conflicts
- conflicting_prs=""
- total=$(echo "$prs_json" | jq 'length')
- current=0
-
- echo -e "${BLUE}Checking $total PRs for merge conflicts...${NC}" >&2
-
- while IFS= read -r pr; do
- ((current++)) || true
- repo=$(echo "$pr" | jq -r '.repository.nameWithOwner')
- number=$(echo "$pr" | jq -r '.number')
- title=$(echo "$pr" | jq -r '.title')
- url=$(echo "$pr" | jq -r '.url')
-
- echo -ne "${YELLOW}\rChecking PR $current/$total...${NC}" >&2
-
- # Fetch detailed PR info including mergeable status
- pr_details=$(gh pr view "$number" -R "$repo" \
- --json mergeable,headRefName,baseRefName,author 2>/dev/null || echo "{}")
-
- mergeable=$(echo "$pr_details" | jq -r '.mergeable // "UNKNOWN"')
-
- if [ "$mergeable" = "CONFLICTING" ]; then
- branch=$(echo "$pr_details" | jq -r '.headRefName')
- base_branch=$(echo "$pr_details" | jq -r '.baseRefName')
- author=$(echo "$pr_details" | jq -r '.author.login')
- conflicting_prs+="$repo|#$number|$title|@$author|$branch|$base_branch|$url"$'\n'
- fi
- done < <(echo "$prs_json" | jq -c '.[]')
-
- echo -e "\r${GREEN}Done checking PRs.${NC} " >&2
-
- if [ -z "$conflicting_prs" ]; then
- echo -e "${GREEN}No pull requests with merge conflicts found!${NC}"
- exit 0
- fi
-
- echo -e "${YELLOW}Found pull requests with merge conflicts:${NC}" >&2
- echo ""
-
- # Use fzf to select PRs
- selected_prs=$(echo "$conflicting_prs" | fzf \
- --multi \
- --ansi \
- --delimiter='|' \
- --with-nth=1,2,3,4 \
- --header="Select PRs to resolve conflicts (TAB for multi-select, ENTER to confirm)" \
- --preview="echo {} | cut -d'|' -f7 | xargs -I % echo 'URL: %'; echo ''; repo=\$(echo {} | cut -d'|' -f1); pr=\$(echo {} | cut -d'|' -f2 | tr -d '#'); gh pr diff \"\$pr\" -R \"\$repo\" 2>/dev/null | head -50 || echo 'Loading...'" \
- --preview-window=right:60%:wrap \
- --bind='ctrl-/:toggle-preview' \
- --height=80%)
-
- if [ -z "$selected_prs" ]; then
- echo -e "${YELLOW}No pull requests selected.${NC}"
- exit 0
- fi
-fi
-
-echo ""
-echo -e "${BLUE}Processing selected pull requests...${NC}"
-echo ""
-
-# Function to resolve conflicts with emacs ediff
-resolve_with_ediff() {
- local file="$1"
-
- if [ "$HAS_EMACS" = false ]; then
- echo -e "${YELLOW}Emacs not available, using git mergetool...${NC}"
- git mergetool "$file"
- return $?
- fi
-
- echo -e "${GREEN}Launching emacs ediff for: $file${NC}"
-
- # Create temporary elisp script for ediff
- local ediff_script
- ediff_script=$(mktemp)
- cat > "$ediff_script" <<'ELISP'
-(defun resolve-conflict-and-quit ()
- "Resolve git conflict with ediff and quit when done."
- (let* ((file (car command-line-args-left))
- (buffer (find-file-noselect file)))
- (with-current-buffer buffer
- ;; Check if file has conflict markers
- (goto-char (point-min))
- (if (search-forward "<<<<<<< " nil t)
- (progn
- ;; Use ediff-merge for 3-way merge
- (let* ((base-file (concat file ".base"))
- (local-file (concat file ".LOCAL"))
- (remote-file (concat file ".REMOTE")))
- (if (and (file-exists-p local-file)
- (file-exists-p remote-file))
- ;; If git created the temp files, use them
- (ediff-merge-files-with-ancestor local-file remote-file base-file nil file)
- ;; Otherwise, try to extract from conflict markers
- (vc-resolve-conflicts))))
- ;; No conflict markers found
- (message "No conflict markers found in %s" file)))
- (setq command-line-args-left nil)))
-
-(add-hook 'ediff-quit-hook
- (lambda ()
- (save-buffers-kill-terminal t)))
-
-(resolve-conflict-and-quit)
-ELISP
-
- # Launch emacs with ediff (using user's config)
- emacs --load "$ediff_script" "$file" 2>/dev/null
-
- rm -f "$ediff_script"
-
- # Check if conflict markers still exist
- if grep -q "<<<<<<< " "$file"; then
- echo -e "${RED}Conflict markers still present in $file${NC}"
- return 1
- fi
-
- # Mark as resolved
- git add "$file"
- return 0
-}
-
-# Function to resolve a single PR
-resolve_pr() {
- local repo="$1"
- local pr_number="$2"
- local pr_title="$3"
- local pr_branch="$4"
- local base_branch="$5"
- local pr_url="$6"
-
- echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
- echo -e "${BLUE}Repository: $repo${NC}"
- echo -e "${BLUE}PR #$pr_number: $pr_title${NC}"
- echo -e "${BLUE}Branch: $pr_branch -> $base_branch${NC}"
- echo -e "${BLUE}URL: $pr_url${NC}"
- echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
- echo ""
-
- # Get PR details to find the head repository (fork)
- echo -e "${YELLOW}Finding fork repository...${NC}"
- pr_details=$(gh pr view "$pr_number" -R "$repo" \
- --json headRepository,headRepositoryOwner,isCrossRepository 2>/dev/null)
-
- if [ -z "$pr_details" ]; then
- echo -e "${RED}Failed to get PR details${NC}"
- return 1
- fi
-
- local is_cross_repo
- is_cross_repo=$(echo "$pr_details" | jq -r '.isCrossRepository')
- local fork_repo
-
- if [ "$is_cross_repo" = "true" ]; then
- # PR is from a fork - construct fork repo name
- local fork_owner
- fork_owner=$(echo "$pr_details" | jq -r '.headRepositoryOwner.login')
- local fork_name
- fork_name=$(echo "$pr_details" | jq -r '.headRepository.name')
-
- # Try to get full nameWithOwner, fall back to constructing it
- fork_repo=$(echo "$pr_details" | jq -r '.headRepository.nameWithOwner')
- if [ -z "$fork_repo" ] || [ "$fork_repo" = "null" ] || [ "$fork_repo" = "" ]; then
- fork_repo="$fork_owner/$fork_name"
- fi
-
- echo -e "${BLUE}PR is from fork: $fork_repo${NC}"
- else
- # PR is from same repo (branch)
- fork_repo="$repo"
- echo -e "${BLUE}PR is from branch in same repo${NC}"
- fi
-
- local work_dir
-
- if [ "$USE_WORKTREE" = true ]; then
- # Use worktree
- local repo_name
- repo_name=$(echo "$repo" | tr '/' '-')
- work_dir="$WORKTREE_DIR/$repo_name/pr-$pr_number"
-
- echo -e "${YELLOW}Creating worktree at: $work_dir${NC}"
-
- # Create parent directory
- mkdir -p "$WORKTREE_DIR/$repo_name"
-
- # Clone fork if not exists
- local repo_dir="$WORKTREE_DIR/$repo_name/main"
- if [ ! -d "$repo_dir" ]; then
- echo -e "${YELLOW}Cloning fork: $fork_repo...${NC}"
- gh repo clone "$fork_repo" "$repo_dir" -- --bare
-
- # Add upstream remote if this is a fork
- if [ "$is_cross_repo" = "true" ]; then
- echo -e "${YELLOW}Adding upstream remote: $repo...${NC}"
- git -C "$repo_dir" remote add upstream "https://github.com/$repo.git" 2>/dev/null || true
- fi
- else
- echo -e "${YELLOW}Fetching latest changes from fork...${NC}"
- git -C "$repo_dir" fetch origin
-
- # Ensure upstream remote exists if this is a fork
- if [ "$is_cross_repo" = "true" ]; then
- if ! git -C "$repo_dir" remote | grep -q "^upstream$"; then
- echo -e "${YELLOW}Adding upstream remote: $repo...${NC}"
- git -C "$repo_dir" remote add upstream "https://github.com/$repo.git"
- fi
- fi
- fi
-
- # Fetch from upstream if this is a fork
- if [ "$is_cross_repo" = "true" ]; then
- echo -e "${YELLOW}Fetching from upstream: $repo...${NC}"
- git -C "$repo_dir" fetch upstream
- fi
-
- # Remove existing worktree if present
- if [ -d "$work_dir" ]; then
- echo -e "${YELLOW}Removing existing worktree...${NC}"
- git -C "$repo_dir" worktree remove "$work_dir" --force 2>/dev/null || rm -rf "$work_dir"
- fi
-
- # Fetch PR branch from fork
- echo -e "${YELLOW}Fetching PR branch: $pr_branch...${NC}"
- git -C "$repo_dir" fetch origin "$pr_branch:pr-$pr_number" || {
- echo -e "${RED}Failed to fetch PR branch from fork${NC}"
- return 1
- }
-
- # Create new worktree
- echo -e "${YELLOW}Creating worktree for branch $pr_branch...${NC}"
- git -C "$repo_dir" worktree add "$work_dir" "pr-$pr_number" || {
- echo -e "${RED}Failed to create worktree${NC}"
- return 1
- }
-
- cd "$work_dir"
- else
- # Use existing repo
- echo -e "${YELLOW}Using existing repository (no worktree)${NC}"
-
- # Determine which repo we should be in
- current_repo=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo "")
-
- # We should be in the fork, not upstream
- if [ "$current_repo" != "$fork_repo" ]; then
- echo -e "${RED}Error: Current directory is $current_repo, expected $fork_repo${NC}"
- echo -e "${YELLOW}Please cd to your fork or use --worktree mode${NC}"
- return 1
- fi
-
- work_dir=$(pwd)
-
- # Ensure upstream remote exists if this is a fork
- if [ "$is_cross_repo" = "true" ]; then
- if ! git remote | grep -q "^upstream$"; then
- echo -e "${YELLOW}Adding upstream remote: $repo...${NC}"
- git remote add upstream "https://github.com/$repo.git"
- fi
- echo -e "${YELLOW}Fetching from upstream...${NC}"
- git fetch upstream
- fi
-
- # Fetch PR
- echo -e "${YELLOW}Fetching PR #$pr_number...${NC}"
- gh pr checkout "$pr_number" -R "$repo" || {
- echo -e "${RED}Failed to checkout PR${NC}"
- return 1
- }
- fi
-
- # Determine the correct remote for base branch
- local base_remote
- if [ "$is_cross_repo" = "true" ]; then
- base_remote="upstream"
- else
- base_remote="origin"
- fi
-
- # Fetch base branch
- echo -e "${YELLOW}Fetching base branch from $base_remote: $base_branch${NC}"
- git fetch "$base_remote" "$base_branch" || {
- echo -e "${RED}Failed to fetch base branch${NC}"
- return 1
- }
-
- # Start rebase
- echo -e "${YELLOW}Starting rebase onto $base_remote/$base_branch...${NC}"
- echo ""
-
- if git rebase "$base_remote/$base_branch"; then
- echo -e "${GREEN}✓ Rebase completed successfully with no conflicts!${NC}"
- else
- echo -e "${YELLOW}Conflicts detected. Starting conflict resolution...${NC}"
- echo ""
-
- # Get list of conflicted files
- conflicted_files=$(git diff --name-only --diff-filter=U)
-
- if [ -z "$conflicted_files" ]; then
- echo -e "${RED}Error: Rebase failed but no conflicted files found${NC}"
- git rebase --abort
- return 1
- fi
-
- echo -e "${BLUE}Conflicted files:${NC}"
- echo "$conflicted_files" | while read -r file; do
- echo -e " ${RED}✗${NC} $file"
- done
- echo ""
-
- # Resolve each conflict
- while read -r file; do
- echo -e "${BLUE}Resolving: $file${NC}"
-
- if ! resolve_with_ediff "$file"; then
- echo -e "${RED}Failed to resolve conflict in $file${NC}"
- echo -e "${YELLOW}Options:${NC}"
- echo -e " ${YELLOW}1)${NC} Skip this file and continue"
- echo -e " ${YELLOW}2)${NC} Abort rebase"
- echo -e " ${YELLOW}3)${NC} Open file manually"
- read -rp "Choice [1-3]: " choice
-
- case $choice in
- 1)
- echo -e "${YELLOW}Skipping $file${NC}"
- continue
- ;;
- 2)
- echo -e "${YELLOW}Aborting rebase${NC}"
- git rebase --abort
- return 1
- ;;
- 3)
- ${EDITOR:-vim} "$file"
- git add "$file"
- ;;
- *)
- echo -e "${RED}Invalid choice, aborting${NC}"
- git rebase --abort
- return 1
- ;;
- esac
- fi
- done <<< "$conflicted_files"
-
- # Continue rebase
- echo -e "${YELLOW}Continuing rebase...${NC}"
- if git rebase --continue; then
- echo -e "${GREEN}✓ Rebase completed successfully!${NC}"
- else
- echo -e "${RED}Failed to continue rebase${NC}"
- echo -e "${YELLOW}You may need to resolve remaining conflicts manually${NC}"
- echo -e "${YELLOW}Working directory: $work_dir${NC}"
- return 1
- fi
- fi
-
- echo ""
- echo -e "${GREEN}✓ Conflicts resolved successfully!${NC}"
- echo ""
-
- # Push changes
- if [ "$AUTO_PUSH" = true ]; then
- echo -e "${YELLOW}Force-pushing changes...${NC}"
- if git push --force-with-lease; then
- echo -e "${GREEN}✓ Changes pushed successfully!${NC}"
- else
- echo -e "${RED}Failed to push changes${NC}"
- echo -e "${YELLOW}You may need to push manually from: $work_dir${NC}"
- return 1
- fi
- else
- echo -e "${YELLOW}Changes not pushed. To push manually:${NC}"
- echo -e " cd $work_dir"
- echo -e " git push --force-with-lease"
- fi
-
- echo ""
-
- # Cleanup worktree
- if [ "$USE_WORKTREE" = true ]; then
- echo -e "${YELLOW}Note: Worktree kept at: $work_dir${NC}"
- echo -e "${YELLOW}To remove: git worktree remove $work_dir${NC}"
- fi
-
- return 0
-}
-
-# Process each selected PR
-while IFS='|' read -r repo pr_number pr_title pr_author pr_branch base_branch pr_url; do
- pr_number=$(echo "$pr_number" | tr -d '#' | xargs)
-
- if ! resolve_pr "$repo" "$pr_number" "$pr_title" "$pr_branch" "$base_branch" "$pr_url"; then
- echo -e "${RED}Failed to resolve PR #$pr_number${NC}"
- echo ""
- continue
- fi
-
- echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
- echo ""
-done <<< "$selected_prs"
-
-echo -e "${GREEN}Done!${NC}"
tools/gh-resolve-conflicts/README.md
@@ -1,236 +0,0 @@
-# gh-resolve-conflicts
-
-Interactive tool to scan, identify, and resolve merge conflicts in GitHub pull requests using emacs ediff.
-
-## Features
-
-- **Automatic Scanning**: Searches for all open PRs with merge conflicts across organizations
-- **Interactive Selection**: Uses fzf for multi-select PR picking with preview
-- **Fork-Aware**: Automatically detects and clones your fork, sets up upstream remote
-- **Git Worktrees**: Isolates conflict resolution in separate worktrees (or uses existing repo)
-- **Emacs Ediff Integration**: Launches emacs with ediff-merge for 3-way conflict resolution
-- **Automated Rebase**: Handles git rebase workflow automatically against upstream
-- **Auto-Push by Default**: Automatically force-pushes resolved changes to your fork (disable with `--no-push`)
-- **Uses Your Emacs Config**: Launches ediff with your full emacs configuration
-
-## Installation
-
-### With Nix
-
-Add to your Nix configuration:
-
-```nix
-let
- gh-resolve-conflicts = pkgs.callPackage ./tools/gh-resolve-conflicts { };
-in {
- home.packages = [ gh-resolve-conflicts ];
-}
-```
-
-### Manual
-
-```bash
-# Make executable
-chmod +x gh-resolve-conflicts.sh
-
-# Optionally link to PATH
-ln -s "$(pwd)/gh-resolve-conflicts.sh" ~/.local/bin/gh-resolve-conflicts
-```
-
-## Dependencies
-
-- `gh` (GitHub CLI)
-- `fzf` (fuzzy finder, for interactive mode)
-- `jq` (JSON processor)
-- `git`
-- `emacs` (for ediff conflict resolution, falls back to git mergetool if not available)
-
-## Usage
-
-### Interactive Mode
-
-Scan all your PRs in the tektoncd organization:
-
-```bash
-gh-resolve-conflicts -o tektoncd
-```
-
-Scan all your PRs across all organizations:
-
-```bash
-gh-resolve-conflicts
-```
-
-### Direct PR Mode
-
-Resolve a specific PR directly:
-
-```bash
-gh-resolve-conflicts tektoncd/mcp-server#94
-```
-
-### Options
-
-```
--w, --worktree DIR Create worktrees in DIR (default: /tmp/gh-resolve-conflicts-worktrees)
--n, --no-worktree Use existing repo instead of creating worktrees
--N, --no-push Do NOT automatically force-push after resolution (default: auto-push)
--o, --org ORG Filter PRs by organization
--a, --author AUTHOR Filter PRs by author (default: @me)
--h, --help Show help message
-```
-
-### Examples
-
-**Interactive selection (auto-pushes by default):**
-```bash
-gh-resolve-conflicts -o tektoncd
-```
-
-**Interactive selection without auto-push:**
-```bash
-gh-resolve-conflicts -o tektoncd -N
-```
-
-**Use existing repo (no worktree):**
-```bash
-cd ~/src/tektoncd/pipeline
-gh-resolve-conflicts -n tektoncd/pipeline#9197
-```
-
-**Custom worktree directory:**
-```bash
-gh-resolve-conflicts -w ~/tmp/worktrees -o tektoncd
-```
-
-## Workflow
-
-1. **Scan**: Tool searches for open PRs with merge conflicts
-2. **Select**: Interactive fzf interface shows conflicting PRs with preview
-3. **Fork Detection**: Automatically identifies your fork of the upstream repository
-4. **Setup**:
- - Clones your fork (not upstream)
- - Adds upstream as a remote
- - Fetches from both fork and upstream
-5. **Checkout**: Creates worktree and checks out PR branch from your fork
-6. **Rebase**: Attempts rebase against `upstream/base-branch`
-7. **Resolve**: When conflicts occur:
- - Lists all conflicted files
- - Launches emacs ediff for each file
- - Ediff provides 3-way merge interface
- - Automatically marks files as resolved after ediff
-8. **Complete**: Continues rebase after all conflicts resolved
-9. **Push**: Automatically force-pushes changes to your fork (with `--force-with-lease`, unless `--no-push`)
-
-## Emacs Ediff
-
-When conflicts are detected, the tool launches emacs with ediff-merge using your full emacs configuration:
-
-- **Uses Your Config**: Loads your complete emacs setup (themes, packages, keybindings)
-- **3-way merge interface**: See your changes, their changes, and the common ancestor
-- **Visual conflict resolution**: Navigate conflicts with keyboard shortcuts
-- **Automatic staging**: Resolved files are automatically git-added
-
-### Ediff Controls
-
-- `n` / `p`: Next/previous conflict
-- `a`: Choose variant A (yours)
-- `b`: Choose variant B (theirs)
-- `ab` / `ba`: Combine both variants
-- `q`: Quit ediff (saves and marks resolved)
-
-## Worktree Isolation
-
-By default, the tool creates git worktrees for each PR resolution:
-
-**Benefits:**
-- Isolates work from your main repository
-- Multiple PRs can be resolved in parallel
-- Original repo remains untouched
-- Easy cleanup
-- Automatically handles fork setup (origin = your fork, upstream = original repo)
-
-**Location:**
-```
-/tmp/gh-resolve-conflicts-worktrees/
-├── tektoncd-pipeline/
-│ ├── main/ (bare clone of YOUR fork)
-│ │ ├── origin -> vdemeester/tektoncd-pipeline (your fork)
-│ │ └── upstream -> tektoncd/pipeline (upstream)
-│ └── pr-9197/ (worktree for PR #9197)
-└── tektoncd-mcp-server/
- ├── main/ (bare clone of YOUR fork)
- │ ├── origin -> vdemeester/tektoncd-mcp-server
- │ └── upstream -> tektoncd/mcp-server
- └── pr-94/ (worktree for PR #94)
-```
-
-**Cleanup:**
-```bash
-# Remove specific worktree
-git worktree remove /tmp/gh-resolve-conflicts-worktrees/tektoncd-pipeline/pr-9197
-
-# Or just delete the directory
-rm -rf /tmp/gh-resolve-conflicts-worktrees/
-```
-
-## Use Cases
-
-**Resolve conflicts across multiple repos:**
-```bash
-# Finds all conflicting PRs in tektoncd org and lets you pick which to fix
-gh-resolve-conflicts -o tektoncd
-```
-
-**Quick fix for a single PR:**
-```bash
-# Directly resolve PR #94, auto-pushes when done
-gh-resolve-conflicts tektoncd/mcp-server#94
-```
-
-**Resolve in existing checkout:**
-```bash
-# Use your current repo checkout instead of worktree
-cd ~/src/tektoncd/chains
-gh-resolve-conflicts -n tektoncd/chains#1487
-```
-
-## Troubleshooting
-
-**"emacs not found" warning:**
-- Tool falls back to `git mergetool`
-- Install emacs for better conflict resolution experience
-
-**Ediff doesn't show conflicts:**
-- Some conflicts may need manual resolution
-- Option to open file in $EDITOR is provided
-
-**Rebase fails:**
-- Tool offers options to skip file, abort, or open manually
-- Worktree is preserved for manual intervention
-
-**Can't push after resolution:**
-- Check if you have write access to the repository
-- May need to configure git credentials
-- Use `--force-with-lease` manually if needed
-
-## Related Tools
-
-- `gh-restart-failed`: Restart failed GitHub workflow checks
-- `gh pr checkout`: GitHub CLI built-in PR checkout
-- `gh-pr-worktree`: GitHub CLI extension for PR worktrees
-
-## Contributing
-
-This tool follows the same structure as other tools in `~/src/home/tools/`:
-
-```
-gh-resolve-conflicts/
-├── gh-resolve-conflicts.sh Main script
-├── default.nix Nix package definition
-└── README.md Documentation
-```
-
-## License
-
-MIT
tools/gh-restart-failed/default.nix
@@ -1,38 +0,0 @@
-{
- stdenv,
- lib,
- makeWrapper,
- gh,
- fzf,
- jq,
-}:
-
-stdenv.mkDerivation {
- name = "gh-restart-failed";
- pname = "gh-restart-failed";
- version = "0.1.0";
-
- src = ./.;
-
- nativeBuildInputs = [ makeWrapper ];
-
- installPhase = ''
- mkdir -p $out/bin
- cp gh-restart-failed.sh $out/bin/gh-restart-failed
- chmod +x $out/bin/gh-restart-failed
-
- wrapProgram $out/bin/gh-restart-failed \
- --prefix PATH : ${
- lib.makeBinPath [
- gh
- fzf
- jq
- ]
- }
- '';
-
- meta = {
- description = "List and restart failed GitHub workflow checks on pull requests";
- platforms = lib.platforms.unix;
- };
-}
tools/gh-restart-failed/gh-restart-failed.sh
@@ -1,273 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-# Help message
-usage() {
- cat <<EOF
-Usage: gh-restart-failed [OPTIONS] [REPOSITORY[#PR_NUMBER]]
-
-List pull requests with failed checks and restart selected workflows.
-
-Options:
- -i, --ignore PATTERN Ignore workflows matching PATTERN (can be used multiple times)
- -l, --label LABEL Filter PRs by label (can be used multiple times)
- -h, --help Show this help message
-
-Arguments:
- REPOSITORY Optional repository in OWNER/REPO format or path to local repo.
- If not provided, uses the current directory's repository.
- Can include #PR_NUMBER to directly restart a specific PR (skips interactive selection).
-
-Dependencies:
- - gh (GitHub CLI)
- - fzf (fuzzy finder, only needed for interactive mode)
- - jq (JSON processor)
-
-Note:
- By default, "Label Checker" workflows are ignored. Use -i to add more patterns.
-
-Examples:
- gh-restart-failed # Use current repository (interactive)
- gh-restart-failed owner/repo#123 # Directly restart PR #123 in owner/repo
- gh-restart-failed owner/repo # Use specific GitHub repository (interactive)
- gh-restart-failed -i "build" -i "test" # Ignore build and test workflows
- gh-restart-failed -l "bug" -l "enhancement" # Only show PRs with bug OR enhancement labels
- gh-restart-failed /path/to/repo # Use repository at path
-
-EOF
- exit 0
-}
-
-# Check dependencies
-check_dependencies() {
- local missing=()
-
- for cmd in gh jq; do
- if ! command -v "$cmd" &> /dev/null; then
- missing+=("$cmd")
- fi
- done
-
- if [ ${#missing[@]} -gt 0 ]; then
- echo -e "${RED}Error: Missing required dependencies: ${missing[*]}${NC}" >&2
- echo "Please install them and try again." >&2
- exit 1
- fi
-}
-
-# Default ignore patterns
-IGNORE_PATTERNS=("Label Checker")
-LABEL_FILTERS=()
-
-# Parse arguments
-REPO_ARG=""
-PR_NUMBER=""
-while [[ $# -gt 0 ]]; do
- case $1 in
- -h|--help)
- usage
- ;;
- -i|--ignore)
- if [ -n "${2:-}" ]; then
- IGNORE_PATTERNS+=("$2")
- shift 2
- else
- echo -e "${RED}Error: --ignore requires a pattern argument${NC}" >&2
- exit 1
- fi
- ;;
- -l|--label)
- if [ -n "${2:-}" ]; then
- LABEL_FILTERS+=("$2")
- shift 2
- else
- echo -e "${RED}Error: --label requires a label argument${NC}" >&2
- exit 1
- fi
- ;;
- -*)
- echo -e "${RED}Error: Unknown option: $1${NC}" >&2
- usage
- ;;
- *)
- REPO_ARG="$1"
- # Check if it contains #PR_NUMBER
- if [[ "$REPO_ARG" =~ ^(.+)#([0-9]+)$ ]]; then
- REPO_ARG="${BASH_REMATCH[1]}"
- PR_NUMBER="${BASH_REMATCH[2]}"
- fi
- shift
- ;;
- esac
-done
-
-check_dependencies
-
-# Check fzf only if in interactive mode (no PR_NUMBER specified)
-if [ -z "$PR_NUMBER" ] && ! command -v fzf &> /dev/null; then
- echo -e "${RED}Error: fzf is required for interactive mode${NC}" >&2
- echo "Please install fzf or specify a PR number directly (e.g., owner/repo#123)" >&2
- exit 1
-fi
-
-# Determine repository context
-REPO_FLAG=()
-if [ -n "$REPO_ARG" ]; then
- if [ -d "$REPO_ARG" ]; then
- # It's a directory path
- REPO_FLAG=(-R "$(cd "$REPO_ARG" && gh repo view --json nameWithOwner -q .nameWithOwner)")
- else
- # Assume it's OWNER/REPO format
- REPO_FLAG=(-R "$REPO_ARG")
- fi
-fi
-
-# Show ignored patterns
-if [ ${#IGNORE_PATTERNS[@]} -gt 0 ]; then
- echo -e "${YELLOW}Ignoring workflows matching: ${IGNORE_PATTERNS[*]}${NC}" >&2
-fi
-
-# Show label filters
-if [ ${#LABEL_FILTERS[@]} -gt 0 ]; then
- echo -e "${YELLOW}Filtering PRs with labels: ${LABEL_FILTERS[*]}${NC}" >&2
-fi
-
-# If PR_NUMBER is specified, skip interactive selection
-if [ -n "$PR_NUMBER" ]; then
- echo -e "${BLUE}Fetching PR #$PR_NUMBER...${NC}" >&2
-
- # Fetch specific PR information
- pr_info=$(gh pr view "${REPO_FLAG[@]}" "$PR_NUMBER" \
- --json number,title,headRefName,author \
- 2>/dev/null)
-
- if [ -z "$pr_info" ]; then
- echo -e "${RED}Error: PR #$PR_NUMBER not found${NC}" >&2
- exit 1
- fi
-
- pr_title=$(echo "$pr_info" | jq -r '.title')
- pr_branch=$(echo "$pr_info" | jq -r '.headRefName')
- pr_author=$(echo "$pr_info" | jq -r '.author.login')
-
- # Format as if selected from interactive mode
- selected_prs="#$PR_NUMBER | $pr_title | @$pr_author | $pr_branch | direct"
-else
- # Interactive mode: Get all open PRs with their check status
- echo -e "${BLUE}Fetching pull requests...${NC}" >&2
-
- # Build label filter arguments for gh pr list
- LABEL_ARGS=()
- for label in "${LABEL_FILTERS[@]}"; do
- LABEL_ARGS+=(--label "$label")
- done
-
- # Fetch PRs with detailed check information
- prs_json=$(gh pr list "${REPO_FLAG[@]}" \
- "${LABEL_ARGS[@]}" \
- --json number,title,headRefName,author,statusCheckRollup \
- --limit 100)
-
- # Filter PRs with failed checks and format for display
- failed_prs=$(echo "$prs_json" | jq -r '
- .[] |
- select(.statusCheckRollup // [] | any(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "STARTUP_FAILURE" or .conclusion == "ACTION_REQUIRED")) |
- {
- number: .number,
- title: .title,
- branch: .headRefName,
- author: .author.login,
- failed_checks: [.statusCheckRollup[] | select(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "STARTUP_FAILURE" or .conclusion == "ACTION_REQUIRED")]
- } |
- "#\(.number) | \(.title) | @\(.author) | \(.branch) | \(.failed_checks | length) failed"
- ')
-
- if [ -z "$failed_prs" ]; then
- echo -e "${GREEN}No pull requests with failed checks found!${NC}"
- exit 0
- fi
-
- echo -e "${YELLOW}Found pull requests with failed checks:${NC}" >&2
- echo ""
-
- # Use fzf to select PRs
- selected_prs=$(echo "$failed_prs" | fzf \
- --multi \
- --ansi \
- --header="Select pull requests to restart failed workflows (TAB to select multiple, ENTER to confirm)" \
- --preview="pr_number=\$(echo {} | cut -d'|' -f1 | tr -d '# '); gh pr checks ${REPO_FLAG[*]} \"\$pr_number\" 2>/dev/null | grep -E '(fail|FAILURE|×)' || echo 'Loading...'" \
- --preview-window=right:60%:wrap \
- --bind='ctrl-/:toggle-preview' \
- --height=80%)
-
- if [ -z "$selected_prs" ]; then
- echo -e "${YELLOW}No pull requests selected.${NC}"
- exit 0
- fi
-fi
-
-echo ""
-echo -e "${BLUE}Processing selected pull requests...${NC}"
-echo ""
-
-# Process each selected PR
-while IFS= read -r pr_line; do
- pr_number=$(echo "$pr_line" | cut -d'|' -f1 | tr -d '# ' | xargs)
- pr_title=$(echo "$pr_line" | cut -d'|' -f2 | xargs)
- pr_branch=$(echo "$pr_line" | cut -d'|' -f4 | xargs)
-
- echo -e "${BLUE}PR #$pr_number: $pr_title${NC}"
-
- # Build jq ignore filter
- ignore_filter=""
- for pattern in "${IGNORE_PATTERNS[@]}"; do
- if [ -n "$ignore_filter" ]; then
- ignore_filter="$ignore_filter and "
- fi
- ignore_filter="${ignore_filter}(.name | contains(\"$pattern\") | not)"
- done
-
- # Get failed workflow runs for this PR using the branch
- failed_runs=$(gh run list "${REPO_FLAG[@]}" \
- --branch "$pr_branch" \
- --json databaseId,name,conclusion,status,event \
- --limit 50 \
- | jq -r "
- .[] |
- select(.event == \"pull_request\" and (.conclusion == \"failure\" or .conclusion == \"timed_out\" or .conclusion == \"startup_failure\" or .conclusion == \"action_required\") and ($ignore_filter)) |
- \"\(.databaseId)|\(.name)|\(.conclusion)\"")
-
- if [ -z "$failed_runs" ]; then
- echo -e "${YELLOW} No failed workflow runs found (may have been restarted already)${NC}"
- continue
- fi
-
- # Restart all failed workflow runs
- echo -e "${YELLOW} Restarting failed workflows:${NC}"
-
- echo "$failed_runs" | while IFS='|' read -r run_id workflow_name status; do
- echo -e " ${GREEN}→${NC} Restarting: $workflow_name ($status)"
-
- rerun_output=$(gh run rerun "${REPO_FLAG[@]}" "$run_id" --failed 2>&1)
-
- if echo "$rerun_output" | grep -q "created over a month ago"; then
- echo -e " ${YELLOW}⚠${NC} Cannot restart: workflow run is too old (>1 month)"
- elif echo "$rerun_output" | grep -qi "error"; then
- echo -e " ${RED}✗${NC} Failed to restart: $rerun_output"
- else
- echo -e " ${GREEN}✓${NC} Restarted successfully"
- fi
- done
-
- echo ""
-done <<< "$selected_prs"
-
-echo -e "${GREEN}Done!${NC}"