Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
26 changes: 19 additions & 7 deletions cmd/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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/tree"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -48,16 +49,19 @@ func runLog(cmd *cobra.Command, args []string) error {
g := git.New(cwd)
currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine for display

// Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo)
gh, _ := github.NewClient() //nolint:errcheck // nil is fine, URLs won't be shown

if logPorcelainFlag {
printPorcelain(root, currentBranch)
printPorcelain(root, currentBranch, gh)
} else {
printTree(root, "", true, currentBranch)
printTree(root, "", true, currentBranch, gh)
}

return nil
}

func printTree(node *tree.Node, prefix string, isLast bool, current string) {
func printTree(node *tree.Node, prefix string, isLast bool, current string, gh *github.Client) {
// Determine the branch indicator
connector := "├── "
if isLast {
Expand All @@ -75,7 +79,11 @@ func printTree(node *tree.Node, prefix string, isLast bool, current string) {

prInfo := ""
if node.PR > 0 {
prInfo = fmt.Sprintf(" (#%d)", node.PR)
if gh != nil {
prInfo = fmt.Sprintf(" (#%d) %s", node.PR, gh.PRURL(node.PR))
} else {
prInfo = fmt.Sprintf(" (#%d)", node.PR)
}
}

fmt.Printf("%s%s%s%s%s\n", prefix, connector, marker, node.Name, prInfo)
Expand All @@ -92,11 +100,11 @@ func printTree(node *tree.Node, prefix string, isLast bool, current string) {

for i, child := range node.Children {
isLastChild := i == len(node.Children)-1
printTree(child, childPrefix, isLastChild, current)
printTree(child, childPrefix, isLastChild, current, gh)
}
}

func printPorcelain(node *tree.Node, current string) {
func printPorcelain(node *tree.Node, current string, gh *github.Client) {
var printNode func(*tree.Node, int)
printNode = func(n *tree.Node, depth int) {
isCurrent := "0"
Expand All @@ -107,7 +115,11 @@ func printPorcelain(node *tree.Node, current string) {
if n.Parent != nil {
parent = n.Parent.Name
}
fmt.Printf("%s\t%s\t%d\t%s\n", n.Name, parent, n.PR, isCurrent)
prURL := ""
if n.PR > 0 && gh != nil {
prURL = gh.PRURL(n.PR)
}
fmt.Printf("%s\t%s\t%d\t%s\t%s\n", n.Name, parent, n.PR, isCurrent, prURL)
Comment thread
boneskull marked this conversation as resolved.
for _, child := range n.Children {
printNode(child, depth+1)
}
Expand Down
211 changes: 155 additions & 56 deletions cmd/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,41 @@
package cmd

import (
"context"
"fmt"
"os"
"strings"

gh "github.com/cli/go-gh/v2"
"github.com/spf13/cobra"

"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/tree"
"github.com/spf13/cobra"
)

var prCmd = &cobra.Command{
Use: "pr",
Use: "pr [-- <gh-pr-create-flags>...]",
Short: "Create or update a PR for the current branch",
Long: `Create a new PR targeting the parent branch, or update an existing PR's base.`,
RunE: runPR,
Long: `Create a new PR targeting the parent branch, or update an existing PR's base.

This command wraps 'gh pr create', automatically setting the base branch to the
stack parent. Any additional flags after '--' are passed through to 'gh pr create'.

Examples:
gh stack pr # Interactive PR creation
gh stack pr -- --title "My PR" # With title
gh stack pr -- --fill --web # Fill from commits, open in browser
gh stack pr --base main # Override base branch`,
RunE: runPR,
DisableFlagParsing: false,
}

var prBaseFlag string

func init() {
prCmd.Flags().StringVar(&prBaseFlag, "base", "", "override base branch")
prCmd.Flags().StringVar(&prBaseFlag, "base", "", "override base branch (default: stack parent)")
rootCmd.AddCommand(prCmd)
}

Expand All @@ -37,8 +51,7 @@ func runPR(cmd *cobra.Command, args []string) error {
return err
}

// Create GitHub client
gh, err := github.NewClient()
ghClient, err := github.NewClient()
if err != nil {
return err
}
Expand All @@ -52,80 +65,166 @@ func runPR(cmd *cobra.Command, args []string) error {
// Get parent (base branch)
parent, err := cfg.GetParent(branch)
if err != nil {
return fmt.Errorf("branch %q is not tracked", branch)
}

base := prBaseFlag
if base == "" {
base = parent
return fmt.Errorf("branch %q is not tracked; use 'gh stack create' or 'gh stack track' first", branch)
}

// Get trunk for draft decision and comment generation
trunk, err := cfg.GetTrunk()
if err != nil {
return err
}

base := prBaseFlag
if base == "" {
base = parent
}

// Check if PR already exists
existingPR, _ := cfg.GetPR(branch) //nolint:errcheck // 0 is fine if no PR
if existingPR > 0 {
// Update existing PR's base if needed
fmt.Printf("PR #%d already exists, updating base to %q\n", existingPR, base)
if updateErr := gh.UpdatePRBase(existingPR, base); updateErr != nil {
return fmt.Errorf("failed to update PR base: %w", updateErr)
}
return updateExistingPR(ghClient, cfg, existingPR, branch, base, trunk)
}

// Update stack comment
root, buildErr := tree.Build(cfg)
if buildErr != nil {
return fmt.Errorf("build tree: %w", buildErr)
}
comment := github.GenerateStackComment(root, branch, trunk)
if comment != "" {
if commentErr := gh.CreateOrUpdateStackComment(existingPR, comment); commentErr != nil {
fmt.Printf("Warning: failed to update stack comment: %v\n", commentErr)
}
// Build args for gh pr create
ghArgs := []string{"pr", "create", "--base", base}

// Auto-draft if not targeting trunk (middle of stack)
if base != trunk {
ghArgs = append(ghArgs, "--draft")
fmt.Printf("Creating draft PR (base %q is not trunk %q)\n", base, trunk)
}

// Generate PR body from commits if user hasn't specified --body or --fill
if !hasBodyFlag(args) {
body, bodyErr := generatePRBody(g, base, branch)
if bodyErr != nil {
// Non-fatal: just skip auto-body and let user fill it in
fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr)
} else if body != "" {
ghArgs = append(ghArgs, "--body", body)
}
}

// Pass through any additional args from user
ghArgs = append(ghArgs, args...)

// Let user interact with gh pr create
ctx := context.Background()
if execErr := gh.ExecInteractive(ctx, ghArgs...); execErr != nil {
return fmt.Errorf("gh pr create failed: %w", execErr)
}

// Find the PR we just created
pr, err := ghClient.FindPRByHead(branch)
if err != nil {
return fmt.Errorf("failed to find created PR: %w", err)
}
if pr == nil {
// User might have cancelled
fmt.Println("No PR was created.")
return nil
}

// Create new PR
fmt.Printf("Creating PR for %q targeting %q...\n", branch, base)
// Store PR number
if setErr := cfg.SetPR(branch, pr.Number); setErr != nil {
return setErr
}

var prNumber int
if base != trunk {
// Create as draft since it's part of a stack
prNumber, err = gh.CreateDraftPR(branch, base, branch, "")
if err != nil {
return err
// Post stack navigation comment
root, err := tree.Build(cfg)
if err != nil {
return fmt.Errorf("build tree: %w", err)
}

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

fmt.Printf("Stored PR #%d for branch %q\n", pr.Number, branch)
return nil
}

// updateExistingPR updates the base branch and stack comment for an existing PR.
func updateExistingPR(ghClient *github.Client, cfg *config.Config, prNumber int, branch, base, trunk string) error {
fmt.Printf("PR #%d already exists, updating base to %q\n", prNumber, base)

if err := ghClient.UpdatePRBase(prNumber, base); err != nil {
return fmt.Errorf("failed to update PR base: %w", err)
}

// Update stack comment
root, err := tree.Build(cfg)
if err != nil {
return fmt.Errorf("build tree: %w", err)
}

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

fmt.Println(ghClient.PRURL(prNumber))
return nil
}

// hasBodyFlag checks if the user has provided --body, -b, or --fill flags.
func hasBodyFlag(args []string) bool {
for _, arg := range args {
// Check for --body or -b (with or without = syntax)
if arg == "--body" || arg == "-b" || strings.HasPrefix(arg, "--body=") {
return true
}
fmt.Printf("Created draft PR #%d for %s -> %s\n", prNumber, branch, base)
} else {
prNumber, err = gh.CreatePR(branch, base, branch, "")
if err != nil {
return err
// Check for --fill or -f
if arg == "--fill" || arg == "-f" {
return true
}
// Check for --fill-first
if arg == "--fill-first" {
return true
}
// Check for combined short flags like -bf
if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' {
for _, c := range arg[1:] {
if c == 'b' || c == 'f' {
return true
}
}
}
fmt.Printf("Created PR #%d for %s -> %s\n", prNumber, branch, base)
}
return false
}
Comment thread
boneskull marked this conversation as resolved.

// Store PR number
if setPRErr := cfg.SetPR(branch, prNumber); setPRErr != nil {
return setPRErr
// generatePRBody creates a PR description from the commits between base and head.
// For a single commit: returns the commit body.
// For multiple commits: returns each commit as a markdown section.
func generatePRBody(g *git.Git, base, head string) (string, error) {
commits, err := g.GetCommits(base, head)
if err != nil {
return "", err
}

// Post stack navigation comment
root, buildErr := tree.Build(cfg)
if buildErr != nil {
return fmt.Errorf("build tree: %w", buildErr)
if len(commits) == 0 {
return "", nil
}

comment := github.GenerateStackComment(root, branch, trunk)
if comment != "" {
if err := gh.CreateOrUpdateStackComment(prNumber, comment); err != nil {
fmt.Printf("Warning: failed to add stack comment: %v\n", err)
// Don't fail the command for comment issues
if len(commits) == 1 {
// Single commit: just use the body
return commits[0].Body, nil
}

// Multiple commits: format as markdown sections
var sb strings.Builder
for i, commit := range commits {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString("### ")
sb.WriteString(commit.Subject)
sb.WriteString("\n")
if commit.Body != "" {
sb.WriteString("\n")
sb.WriteString(commit.Body)
sb.WriteString("\n")
}
}

return nil
return sb.String(), nil
}
16 changes: 12 additions & 4 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,21 @@ func updateStackComments(cfg *config.Config, gh *github.Client) error {
return err
}

// Fetch all PR titles in a single request
prNumbers := github.CollectPRNumbers(root)
prInfo, err := gh.GetPRTitles(prNumbers)
if err != nil {
// Non-fatal: we can still render without titles
prInfo = make(map[int]github.PRInfo)
}

// Walk tree and update each PR's comment
return walkTreeAndUpdateComments(root, root, trunk, gh)
return walkTreeAndUpdateComments(root, root, trunk, gh, prInfo)
}

func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.Client) error {
func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.Client, prInfo map[int]github.PRInfo) error {
if node.PR > 0 {
comment := github.GenerateStackComment(root, node.Name, trunk)
comment := github.GenerateStackComment(root, node.Name, trunk, gh.RepoURL(), prInfo)
if comment != "" {
if err := gh.CreateOrUpdateStackComment(node.PR, comment); err != nil {
fmt.Printf("Warning: failed to update comment on PR #%d: %v\n", node.PR, err)
Expand All @@ -59,7 +67,7 @@ func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.C
}

for _, child := range node.Children {
if err := walkTreeAndUpdateComments(child, root, trunk, gh); err != nil {
if err := walkTreeAndUpdateComments(child, root, trunk, gh, prInfo); err != nil {
return err
}
}
Expand Down
Loading
Loading