Skip to content

Commit c08e781

Browse files
authored
PR handling improvements (#7)
1 parent d2c1df5 commit c08e781

9 files changed

Lines changed: 896 additions & 111 deletions

File tree

cmd/log.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/boneskull/gh-stack/internal/config"
99
"github.com/boneskull/gh-stack/internal/git"
10+
"github.com/boneskull/gh-stack/internal/github"
1011
"github.com/boneskull/gh-stack/internal/tree"
1112
"github.com/spf13/cobra"
1213
)
@@ -48,16 +49,19 @@ func runLog(cmd *cobra.Command, args []string) error {
4849
g := git.New(cwd)
4950
currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine for display
5051

52+
// Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo)
53+
gh, _ := github.NewClient() //nolint:errcheck // nil is fine, URLs won't be shown
54+
5155
if logPorcelainFlag {
52-
printPorcelain(root, currentBranch)
56+
printPorcelain(root, currentBranch, gh)
5357
} else {
54-
printTree(root, "", true, currentBranch)
58+
printTree(root, "", true, currentBranch, gh)
5559
}
5660

5761
return nil
5862
}
5963

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

7680
prInfo := ""
7781
if node.PR > 0 {
78-
prInfo = fmt.Sprintf(" (#%d)", node.PR)
82+
if gh != nil {
83+
prInfo = fmt.Sprintf(" (#%d) %s", node.PR, gh.PRURL(node.PR))
84+
} else {
85+
prInfo = fmt.Sprintf(" (#%d)", node.PR)
86+
}
7987
}
8088

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

93101
for i, child := range node.Children {
94102
isLastChild := i == len(node.Children)-1
95-
printTree(child, childPrefix, isLastChild, current)
103+
printTree(child, childPrefix, isLastChild, current, gh)
96104
}
97105
}
98106

99-
func printPorcelain(node *tree.Node, current string) {
107+
// printPorcelain outputs machine-readable tab-separated format:
108+
// BRANCH<tab>PARENT<tab>PR_NUMBER<tab>IS_CURRENT<tab>PR_URL
109+
//
110+
// Fields:
111+
// - BRANCH: branch name
112+
// - PARENT: parent branch name (empty for trunk)
113+
// - PR_NUMBER: associated PR number (0 if none)
114+
// - IS_CURRENT: "1" if current branch, "0" otherwise
115+
// - PR_URL: full PR URL (empty if no PR or GitHub client unavailable)
116+
func printPorcelain(node *tree.Node, current string, gh *github.Client) {
100117
var printNode func(*tree.Node, int)
101118
printNode = func(n *tree.Node, depth int) {
102119
isCurrent := "0"
@@ -107,7 +124,11 @@ func printPorcelain(node *tree.Node, current string) {
107124
if n.Parent != nil {
108125
parent = n.Parent.Name
109126
}
110-
fmt.Printf("%s\t%s\t%d\t%s\n", n.Name, parent, n.PR, isCurrent)
127+
prURL := ""
128+
if n.PR > 0 && gh != nil {
129+
prURL = gh.PRURL(n.PR)
130+
}
131+
fmt.Printf("%s\t%s\t%d\t%s\t%s\n", n.Name, parent, n.PR, isCurrent, prURL)
111132
for _, child := range n.Children {
112133
printNode(child, depth+1)
113134
}

cmd/pr.go

Lines changed: 155 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,41 @@
22
package cmd
33

44
import (
5+
"context"
56
"fmt"
67
"os"
8+
"strings"
9+
10+
gh "github.com/cli/go-gh/v2"
11+
"github.com/spf13/cobra"
712

813
"github.com/boneskull/gh-stack/internal/config"
914
"github.com/boneskull/gh-stack/internal/git"
1015
"github.com/boneskull/gh-stack/internal/github"
1116
"github.com/boneskull/gh-stack/internal/tree"
12-
"github.com/spf13/cobra"
1317
)
1418

1519
var prCmd = &cobra.Command{
16-
Use: "pr",
20+
Use: "pr [-- <gh-pr-create-flags>...]",
1721
Short: "Create or update a PR for the current branch",
18-
Long: `Create a new PR targeting the parent branch, or update an existing PR's base.`,
19-
RunE: runPR,
22+
Long: `Create a new PR targeting the parent branch, or update an existing PR's base.
23+
24+
This command wraps 'gh pr create', automatically setting the base branch to the
25+
stack parent. Any additional flags after '--' are passed through to 'gh pr create'.
26+
27+
Examples:
28+
gh stack pr # Interactive PR creation
29+
gh stack pr -- --title "My PR" # With title
30+
gh stack pr -- --fill --web # Fill from commits, open in browser
31+
gh stack pr --base main # Override base branch`,
32+
RunE: runPR,
33+
DisableFlagParsing: false,
2034
}
2135

2236
var prBaseFlag string
2337

2438
func init() {
25-
prCmd.Flags().StringVar(&prBaseFlag, "base", "", "override base branch")
39+
prCmd.Flags().StringVar(&prBaseFlag, "base", "", "override base branch (default: stack parent)")
2640
rootCmd.AddCommand(prCmd)
2741
}
2842

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

40-
// Create GitHub client
41-
gh, err := github.NewClient()
54+
ghClient, err := github.NewClient()
4255
if err != nil {
4356
return err
4457
}
@@ -52,80 +65,166 @@ func runPR(cmd *cobra.Command, args []string) error {
5265
// Get parent (base branch)
5366
parent, err := cfg.GetParent(branch)
5467
if err != nil {
55-
return fmt.Errorf("branch %q is not tracked", branch)
56-
}
57-
58-
base := prBaseFlag
59-
if base == "" {
60-
base = parent
68+
return fmt.Errorf("branch %q is not tracked; use 'gh stack create' or 'gh stack track' first", branch)
6169
}
6270

63-
// Get trunk for draft decision and comment generation
6471
trunk, err := cfg.GetTrunk()
6572
if err != nil {
6673
return err
6774
}
6875

76+
base := prBaseFlag
77+
if base == "" {
78+
base = parent
79+
}
80+
6981
// Check if PR already exists
7082
existingPR, _ := cfg.GetPR(branch) //nolint:errcheck // 0 is fine if no PR
7183
if existingPR > 0 {
72-
// Update existing PR's base if needed
73-
fmt.Printf("PR #%d already exists, updating base to %q\n", existingPR, base)
74-
if updateErr := gh.UpdatePRBase(existingPR, base); updateErr != nil {
75-
return fmt.Errorf("failed to update PR base: %w", updateErr)
76-
}
84+
return updateExistingPR(ghClient, cfg, existingPR, branch, base, trunk)
85+
}
7786

78-
// Update stack comment
79-
root, buildErr := tree.Build(cfg)
80-
if buildErr != nil {
81-
return fmt.Errorf("build tree: %w", buildErr)
82-
}
83-
comment := github.GenerateStackComment(root, branch, trunk)
84-
if comment != "" {
85-
if commentErr := gh.CreateOrUpdateStackComment(existingPR, comment); commentErr != nil {
86-
fmt.Printf("Warning: failed to update stack comment: %v\n", commentErr)
87-
}
87+
// Build args for gh pr create
88+
ghArgs := []string{"pr", "create", "--base", base}
89+
90+
// Auto-draft if not targeting trunk (middle of stack)
91+
if base != trunk {
92+
ghArgs = append(ghArgs, "--draft")
93+
fmt.Printf("Creating draft PR (base %q is not trunk %q)\n", base, trunk)
94+
}
95+
96+
// Generate PR body from commits if user hasn't specified --body or --fill
97+
if !hasBodyFlag(args) {
98+
body, bodyErr := generatePRBody(g, base, branch)
99+
if bodyErr != nil {
100+
// Non-fatal: just skip auto-body and let user fill it in
101+
fmt.Printf("Warning: could not generate PR body: %v\n", bodyErr)
102+
} else if body != "" {
103+
ghArgs = append(ghArgs, "--body", body)
88104
}
105+
}
106+
107+
// Pass through any additional args from user
108+
ghArgs = append(ghArgs, args...)
109+
110+
// Let user interact with gh pr create
111+
ctx := context.Background()
112+
if execErr := gh.ExecInteractive(ctx, ghArgs...); execErr != nil {
113+
return fmt.Errorf("gh pr create failed: %w", execErr)
114+
}
115+
116+
// Find the PR we just created
117+
pr, err := ghClient.FindPRByHead(branch)
118+
if err != nil {
119+
return fmt.Errorf("failed to find created PR: %w", err)
120+
}
121+
if pr == nil {
122+
// User might have cancelled
123+
fmt.Println("No PR was created.")
89124
return nil
90125
}
91126

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

95-
var prNumber int
96-
if base != trunk {
97-
// Create as draft since it's part of a stack
98-
prNumber, err = gh.CreateDraftPR(branch, base, branch, "")
99-
if err != nil {
100-
return err
132+
// Post stack navigation comment
133+
root, err := tree.Build(cfg)
134+
if err != nil {
135+
return fmt.Errorf("build tree: %w", err)
136+
}
137+
138+
if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, pr.Number); err != nil {
139+
fmt.Printf("Warning: failed to add stack comment: %v\n", err)
140+
}
141+
142+
fmt.Printf("Stored PR #%d for branch %q\n", pr.Number, branch)
143+
return nil
144+
}
145+
146+
// updateExistingPR updates the base branch and stack comment for an existing PR.
147+
func updateExistingPR(ghClient *github.Client, cfg *config.Config, prNumber int, branch, base, trunk string) error {
148+
fmt.Printf("PR #%d already exists, updating base to %q\n", prNumber, base)
149+
150+
if err := ghClient.UpdatePRBase(prNumber, base); err != nil {
151+
return fmt.Errorf("failed to update PR base: %w", err)
152+
}
153+
154+
// Update stack comment
155+
root, err := tree.Build(cfg)
156+
if err != nil {
157+
return fmt.Errorf("build tree: %w", err)
158+
}
159+
160+
if err := ghClient.GenerateAndPostStackComment(root, branch, trunk, prNumber); err != nil {
161+
fmt.Printf("Warning: failed to update stack comment: %v\n", err)
162+
}
163+
164+
fmt.Println(ghClient.PRURL(prNumber))
165+
return nil
166+
}
167+
168+
// hasBodyFlag checks if the user has provided --body, -b, or --fill flags.
169+
func hasBodyFlag(args []string) bool {
170+
for _, arg := range args {
171+
// Check for --body or -b (with or without = syntax)
172+
if arg == "--body" || arg == "-b" || strings.HasPrefix(arg, "--body=") {
173+
return true
101174
}
102-
fmt.Printf("Created draft PR #%d for %s -> %s\n", prNumber, branch, base)
103-
} else {
104-
prNumber, err = gh.CreatePR(branch, base, branch, "")
105-
if err != nil {
106-
return err
175+
// Check for --fill or -f
176+
if arg == "--fill" || arg == "-f" {
177+
return true
178+
}
179+
// Check for --fill-first or --fill-verbose
180+
if arg == "--fill-first" || arg == "--fill-verbose" {
181+
return true
182+
}
183+
// Check for combined short flags like -bf
184+
if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' {
185+
for _, c := range arg[1:] {
186+
if c == 'b' || c == 'f' {
187+
return true
188+
}
189+
}
107190
}
108-
fmt.Printf("Created PR #%d for %s -> %s\n", prNumber, branch, base)
109191
}
192+
return false
193+
}
110194

111-
// Store PR number
112-
if setPRErr := cfg.SetPR(branch, prNumber); setPRErr != nil {
113-
return setPRErr
195+
// generatePRBody creates a PR description from the commits between base and head.
196+
// For a single commit: returns the commit body.
197+
// For multiple commits: returns each commit as a markdown section.
198+
func generatePRBody(g *git.Git, base, head string) (string, error) {
199+
commits, err := g.GetCommits(base, head)
200+
if err != nil {
201+
return "", err
114202
}
115203

116-
// Post stack navigation comment
117-
root, buildErr := tree.Build(cfg)
118-
if buildErr != nil {
119-
return fmt.Errorf("build tree: %w", buildErr)
204+
if len(commits) == 0 {
205+
return "", nil
120206
}
121207

122-
comment := github.GenerateStackComment(root, branch, trunk)
123-
if comment != "" {
124-
if err := gh.CreateOrUpdateStackComment(prNumber, comment); err != nil {
125-
fmt.Printf("Warning: failed to add stack comment: %v\n", err)
126-
// Don't fail the command for comment issues
208+
if len(commits) == 1 {
209+
// Single commit: just use the body
210+
return commits[0].Body, nil
211+
}
212+
213+
// Multiple commits: format as markdown sections
214+
var sb strings.Builder
215+
for i, commit := range commits {
216+
if i > 0 {
217+
sb.WriteString("\n")
218+
}
219+
sb.WriteString("### ")
220+
sb.WriteString(commit.Subject)
221+
sb.WriteString("\n")
222+
if commit.Body != "" {
223+
sb.WriteString("\n")
224+
sb.WriteString(commit.Body)
225+
sb.WriteString("\n")
127226
}
128227
}
129228

130-
return nil
229+
return sb.String(), nil
131230
}

cmd/sync.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,21 @@ func updateStackComments(cfg *config.Config, gh *github.Client) error {
4343
return err
4444
}
4545

46+
// Fetch all PR titles in a single request
47+
prNumbers := github.CollectPRNumbers(root)
48+
prInfo, err := gh.GetPRTitles(prNumbers)
49+
if err != nil {
50+
// Non-fatal: we can still render without titles
51+
prInfo = make(map[int]github.PRInfo)
52+
}
53+
4654
// Walk tree and update each PR's comment
47-
return walkTreeAndUpdateComments(root, root, trunk, gh)
55+
return walkTreeAndUpdateComments(root, root, trunk, gh, prInfo)
4856
}
4957

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

6169
for _, child := range node.Children {
62-
if err := walkTreeAndUpdateComments(child, root, trunk, gh); err != nil {
70+
if err := walkTreeAndUpdateComments(child, root, trunk, gh, prInfo); err != nil {
6371
return err
6472
}
6573
}

0 commit comments

Comments
 (0)