Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ clean:
gh-install: build
mkdir -p ~/.local/share/gh/extensions/gh-stack
cp gh-stack ~/.local/share/gh/extensions/gh-stack/
@# Clear macOS extended attributes that can cause hangs
@xattr -c ~/.local/share/gh/extensions/gh-stack/gh-stack 2>/dev/null || true

# Install development tools
tools:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ No remote service required. Your stack relationships stay with your repository.

## Development

To build from source, you'll need Go 1.22+.
To build from source, you'll need Go 1.25+.

```bash
gh repo clone boneskull/gh-stack
Expand Down
3 changes: 2 additions & 1 deletion cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ func runCascade(cmd *cobra.Command, args []string) error {

node := tree.FindNode(root, currentBranch)
if node == nil {
return fmt.Errorf("branch %q is not tracked", currentBranch)
trunk, _ := cfg.GetTrunk() //nolint:errcheck // empty is fine for error message
return fmt.Errorf("branch %q is not tracked in the stack\n\nTo add it, run:\n gh stack adopt %s # to stack on %s\n gh stack adopt -p <parent> # to stack on a different branch", currentBranch, trunk, trunk)
}

// Collect branches to cascade
Expand Down
155 changes: 146 additions & 9 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/boneskull/gh-stack/internal/config"
"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/github"
"github.com/boneskull/gh-stack/internal/prompt"
"github.com/boneskull/gh-stack/internal/state"
"github.com/boneskull/gh-stack/internal/tree"
"github.com/spf13/cobra"
Expand All @@ -33,12 +34,14 @@ var (
submitDryRunFlag bool
submitCurrentOnlyFlag bool
submitUpdateOnlyFlag bool
submitYesFlag bool
)

func init() {
submitCmd.Flags().BoolVar(&submitDryRunFlag, "dry-run", false, "show what would be done without doing it")
submitCmd.Flags().BoolVar(&submitCurrentOnlyFlag, "current-only", false, "only submit current branch, not descendants")
submitCmd.Flags().BoolVar(&submitUpdateOnlyFlag, "update-only", false, "only update existing PRs, don't create new ones")
submitCmd.Flags().BoolVarP(&submitYesFlag, "yes", "y", false, "skip interactive prompts and use auto-generated title/description for PRs")
rootCmd.AddCommand(submitCmd)
}

Expand Down Expand Up @@ -82,7 +85,8 @@ func runSubmit(cmd *cobra.Command, args []string) error {

node := tree.FindNode(root, currentBranch)
if node == nil {
return fmt.Errorf("branch %q is not tracked", currentBranch)
trunk, _ := cfg.GetTrunk() //nolint:errcheck // empty is fine for error message
return fmt.Errorf("branch %q is not tracked in the stack\n\nTo add it, run:\n gh stack adopt %s # to stack on %s\n gh stack adopt -p <parent> # to stack on a different branch", currentBranch, trunk, trunk)
}

// Collect branches to submit (current + descendants)
Expand Down Expand Up @@ -179,9 +183,11 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr
if dryRun {
fmt.Printf("Would create PR for %s (base: %s)\n", b.Name, parent)
} else {
prNum, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk)
prNum, adopted, err := createPRForBranch(g, ghClient, cfg, root, b.Name, parent, trunk)
if err != nil {
fmt.Printf("Warning: failed to create PR for %s: %v\n", b.Name, err)
} else if adopted {
fmt.Printf("Adopted PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum))
} else {
fmt.Printf("Created PR #%d for %s (%s)\n", prNum, b.Name, ghClient.PRURL(prNum))
}
Expand All @@ -195,26 +201,42 @@ func doSubmitPRs(g *git.Git, cfg *config.Config, root *tree.Node, branches []*tr
}

// createPRForBranch creates a PR for the given branch and stores the PR number.
func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, error) {
// If a PR already exists for the branch, it adopts the existing PR instead.
// Returns (prNumber, adopted, error) where adopted is true if we adopted an existing PR.
func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, bool, error) {
// Determine if draft (not targeting trunk = middle of stack)
draft := base != trunk

// Generate default title from branch name
defaultTitle := generateTitleFromBranch(branch)

// Generate PR body from commits
body, bodyErr := generatePRBody(g, base, branch)
defaultBody, bodyErr := generatePRBody(g, base, branch)
if bodyErr != nil {
// Non-fatal: just skip auto-body
fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr)
body = ""
defaultBody = ""
}

pr, err := ghClient.CreateSubmitPR(branch, base, body, draft)
// Get title and body (prompt if interactive and --yes not set)
title, body, err := promptForPRDetails(branch, defaultTitle, defaultBody)
if err != nil {
return 0, err
return 0, false, fmt.Errorf("failed to get PR details: %w", err)
}

pr, err := ghClient.CreateSubmitPR(branch, base, title, body, draft)
if err != nil {
// Check if PR already exists - if so, adopt it
if strings.Contains(err.Error(), "pull request already exists") {
prNum, adoptErr := adoptExistingPR(ghClient, cfg, root, branch, base, trunk)
return prNum, true, adoptErr
}
return 0, false, err
}

// Store PR number in config
if err := cfg.SetPR(branch, pr.Number); err != nil {
return pr.Number, fmt.Errorf("PR created but failed to store number: %w", err)
return pr.Number, false, fmt.Errorf("PR created but failed to store number: %w", err)
}

// Update the tree node's PR number so stack comments render correctly
Expand All @@ -227,7 +249,122 @@ func createPRForBranch(g *git.Git, ghClient *github.Client, cfg *config.Config,
fmt.Printf("Warning: failed to add stack comment to PR #%d: %v\n", pr.Number, err)
}

return pr.Number, nil
return pr.Number, false, nil
}

// generateTitleFromBranch creates a PR title from a branch name.
// Replaces - and _ with spaces and converts to title case.
func generateTitleFromBranch(branch string) string {
title := strings.ReplaceAll(branch, "-", " ")
title = strings.ReplaceAll(title, "_", " ")
return toTitleCase(title)
}

// toTitleCase converts a string to title case (first letter of each word capitalized).
func toTitleCase(s string) string {
words := strings.Fields(s)
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, " ")
}

// promptForPRDetails prompts the user for PR title and body.
// If --yes flag is set or stdin is not a TTY, returns the defaults without prompting.
func promptForPRDetails(branch, defaultTitle, defaultBody string) (title, body string, err error) {
// Skip prompts if --yes flag is set
if submitYesFlag {
return defaultTitle, defaultBody, nil
}

// Skip prompts if not interactive
if !prompt.IsInteractive() {
return defaultTitle, defaultBody, nil
}

fmt.Printf("\n--- Creating PR for %s (use --yes to skip prompts) ---\n", branch)

// Prompt for title
title, err = prompt.Input("PR title", defaultTitle)
if err != nil {
return "", "", err
}
title = strings.TrimSpace(title)
if title == "" {
return "", "", fmt.Errorf("PR title cannot be empty")
}

// Show the generated body and ask if user wants to edit
if defaultBody != "" {
fmt.Println("\nGenerated PR description:")
fmt.Println("---")
// Show first few lines or truncate if too long
lines := strings.Split(defaultBody, "\n")
if len(lines) > 10 {
for _, line := range lines[:10] {
fmt.Println(line)
}
fmt.Printf("... (%d more lines)\n", len(lines)-10)
} else {
fmt.Println(defaultBody)
}
fmt.Println("---")
}

editBody, err := prompt.Confirm("Edit description in editor?", false)
if err != nil {
return "", "", err
}

if editBody {
body, err = prompt.EditInEditor(defaultBody)
if err != nil {
fmt.Printf("Warning: editor failed, using generated description: %v\n", err)
body = defaultBody
}
} else {
body = defaultBody
}

fmt.Println()
return title, body, nil
}

// adoptExistingPR finds an existing PR for the branch and adopts it into the stack.
func adoptExistingPR(ghClient *github.Client, cfg *config.Config, root *tree.Node, branch, base, trunk string) (int, error) {
existingPR, err := ghClient.FindPRByHead(branch)
if err != nil {
return 0, fmt.Errorf("failed to find existing PR: %w", err)
}
if existingPR == nil {
return 0, fmt.Errorf("PR creation failed but no existing PR found for branch %q", branch)
}

// Store PR number in config
if err := cfg.SetPR(branch, existingPR.Number); err != nil {
return existingPR.Number, fmt.Errorf("failed to store PR number: %w", err)
}

// Update the tree node's PR number so stack comments render correctly
if node := tree.FindNode(root, branch); node != nil {
node.PR = existingPR.Number
}

// Update PR base to match stack parent
if existingPR.Base.Ref != base {
if err := ghClient.UpdatePRBase(existingPR.Number, base); err != nil {
fmt.Printf("Warning: failed to update base: %v\n", err)
}
}

// Add/update stack navigation comment
if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, existingPR.Number); err != nil {
fmt.Printf("Warning: failed to update stack comment: %v\n", err)
}

return existingPR.Number, nil
}

// generatePRBody creates a PR description from the commits between base and head.
Expand Down
Loading