Skip to content

Commit eaee70f

Browse files
committed
accept branch names too
1 parent e758e96 commit eaee70f

2 files changed

Lines changed: 648 additions & 113 deletions

File tree

cmd/link.go

Lines changed: 188 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,36 +11,57 @@ import (
1111
"github.com/spf13/cobra"
1212
)
1313

14+
type linkOptions struct {
15+
base string
16+
draft bool
17+
}
18+
1419
func LinkCmd(cfg *config.Config) *cobra.Command {
20+
opts := &linkOptions{}
21+
1522
cmd := &cobra.Command{
16-
Use: "link <pr-number> <pr-number> [<pr-number>...]",
23+
Use: "link <branch-or-pr> <branch-or-pr> [<branch-or-pr>...]",
1724
Short: "Link PRs into a stack on GitHub without local tracking",
18-
Long: `Create or update a stack on GitHub from a list of PR numbers.
25+
Long: `Create or update a stack on GitHub from branch names or PR numbers.
1926
2027
This command works entirely via the GitHub API and does not modify
2128
any local state. It is designed for users who manage branches with
2229
external tools (e.g. jj) and want to use GitHub stacked PRs without
2330
adopting local stack tracking.
2431
25-
PR numbers must be provided in stack order (bottom to top). The first
26-
PR's base branch is the trunk of the stack, and each subsequent PR
27-
should target the previous PR's head branch.
32+
Arguments are provided in stack order (bottom to top). Each argument
33+
can be a branch name or a PR number. For numeric arguments, the
34+
command first checks if a PR with that number exists; if not, it
35+
treats the argument as a branch name.
36+
37+
For branches that already have open PRs, those PRs are used. For
38+
branches without PRs, new PRs are created automatically with the
39+
correct base branch chaining.
2840
2941
If the PRs are not yet in a stack, a new stack is created. If some of
3042
the PRs are already in a stack, the existing stack is updated to include
3143
the new PRs (existing PRs are never removed).`,
3244
Args: cobra.MinimumNArgs(2),
3345
RunE: func(cmd *cobra.Command, args []string) error {
34-
return runLink(cfg, args)
46+
return runLink(cfg, opts, args)
3547
},
3648
}
3749

50+
cmd.Flags().StringVar(&opts.base, "base", "main", "Base branch for the bottom of the stack")
51+
cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create new PRs as drafts")
52+
3853
return cmd
3954
}
4055

41-
func runLink(cfg *config.Config, args []string) error {
42-
prNumbers, err := parsePRNumbers(args)
43-
if err != nil {
56+
// resolvedArg holds the result of resolving a single CLI argument to a PR.
57+
type resolvedArg struct {
58+
branch string // head branch name
59+
prNumber int // PR number
60+
prURL string // PR URL (for display)
61+
}
62+
63+
func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
64+
if err := validateArgs(args); err != nil {
4465
cfg.Errorf("%s", err)
4566
return ErrInvalidArgs
4667
}
@@ -51,6 +72,164 @@ func runLink(cfg *config.Config, args []string) error {
5172
return ErrAPIFailure
5273
}
5374

75+
// Phase 1: Resolve each arg to a PR
76+
resolved, err := resolveAllArgs(cfg, client, opts, args)
77+
if err != nil {
78+
return err
79+
}
80+
81+
// Phase 2: Fix base branches for existing PRs with wrong bases
82+
if err := fixBaseBranches(cfg, client, opts, resolved); err != nil {
83+
return err
84+
}
85+
86+
// Phase 3: Upsert the stack
87+
prNumbers := make([]int, len(resolved))
88+
for i, r := range resolved {
89+
prNumbers[i] = r.prNumber
90+
}
91+
92+
return upsertStack(cfg, client, prNumbers)
93+
}
94+
95+
// validateArgs checks for duplicates in the arg list.
96+
func validateArgs(args []string) error {
97+
seen := make(map[string]bool, len(args))
98+
for _, arg := range args {
99+
if seen[arg] {
100+
return fmt.Errorf("duplicate argument: %q", arg)
101+
}
102+
seen[arg] = true
103+
}
104+
return nil
105+
}
106+
107+
// resolveAllArgs resolves each CLI argument to a PR.
108+
// Numeric args are tried as PR numbers first, then as branch names.
109+
// Non-numeric args are treated as branch names. If no open PR exists
110+
// for a branch, a new PR is created.
111+
func resolveAllArgs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string) ([]resolvedArg, error) {
112+
resolved := make([]resolvedArg, 0, len(args))
113+
114+
for i, arg := range args {
115+
r, err := resolveArg(cfg, client, opts, arg, i, resolved)
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
// Check for duplicate PR numbers (different args resolving to same PR)
121+
for _, prev := range resolved {
122+
if prev.prNumber == r.prNumber {
123+
cfg.Errorf("arguments %q and %q resolve to the same PR #%d", prev.branch, r.branch, r.prNumber)
124+
return nil, ErrInvalidArgs
125+
}
126+
}
127+
128+
resolved = append(resolved, *r)
129+
}
130+
131+
return resolved, nil
132+
}
133+
134+
// resolveArg resolves a single argument to a PR.
135+
func resolveArg(cfg *config.Config, client github.ClientOps, opts *linkOptions, arg string, index int, previous []resolvedArg) (*resolvedArg, error) {
136+
// If numeric, try as PR number first
137+
if n, err := strconv.Atoi(arg); err == nil && n > 0 {
138+
pr, err := client.FindPRByNumber(n)
139+
if err != nil {
140+
cfg.Warningf("failed to look up PR #%d: %v", n, err)
141+
// Fall through to branch lookup
142+
} else if pr != nil {
143+
return &resolvedArg{
144+
branch: pr.HeadRefName,
145+
prNumber: pr.Number,
146+
prURL: pr.URL,
147+
}, nil
148+
}
149+
// PR not found — fall through to treat as branch name
150+
}
151+
152+
// Treat as branch name: look for an open PR
153+
return resolveAsBranch(cfg, client, opts, arg, index, previous)
154+
}
155+
156+
// resolveAsBranch looks up an open PR for a branch name. If none exists,
157+
// creates a new PR with the correct base branch.
158+
func resolveAsBranch(cfg *config.Config, client github.ClientOps, opts *linkOptions, branch string, index int, previous []resolvedArg) (*resolvedArg, error) {
159+
pr, err := client.FindPRForBranch(branch)
160+
if err != nil {
161+
cfg.Errorf("failed to look up PR for branch %s: %v", branch, err)
162+
return nil, ErrAPIFailure
163+
}
164+
165+
if pr != nil {
166+
cfg.Printf("Found PR %s for branch %s", cfg.PRLink(pr.Number, pr.URL), branch)
167+
return &resolvedArg{
168+
branch: branch,
169+
prNumber: pr.Number,
170+
prURL: pr.URL,
171+
}, nil
172+
}
173+
174+
// No PR exists — create one
175+
baseBranch := opts.base
176+
if index > 0 {
177+
baseBranch = previous[index-1].branch
178+
}
179+
180+
title := humanize(branch)
181+
body := generatePRBody("")
182+
183+
newPR, err := client.CreatePR(baseBranch, branch, title, body, opts.draft)
184+
if err != nil {
185+
cfg.Errorf("failed to create PR for branch %s: %v", branch, err)
186+
return nil, ErrAPIFailure
187+
}
188+
189+
cfg.Successf("Created PR %s for %s (base: %s)", cfg.PRLink(newPR.Number, newPR.URL), branch, baseBranch)
190+
return &resolvedArg{
191+
branch: branch,
192+
prNumber: newPR.Number,
193+
prURL: newPR.URL,
194+
}, nil
195+
}
196+
197+
// fixBaseBranches updates the base branch of existing PRs to match the
198+
// expected stack chain. The first PR should have base = opts.base,
199+
// each subsequent PR should have base = previous PR's head branch.
200+
func fixBaseBranches(cfg *config.Config, client github.ClientOps, opts *linkOptions, resolved []resolvedArg) error {
201+
for i, r := range resolved {
202+
expectedBase := opts.base
203+
if i > 0 {
204+
expectedBase = resolved[i-1].branch
205+
}
206+
207+
// Look up the PR to check its current base
208+
pr, err := client.FindPRByNumber(r.prNumber)
209+
if err != nil {
210+
cfg.Warningf("could not verify base branch for PR %s: %v",
211+
cfg.PRLink(r.prNumber, r.prURL), err)
212+
continue
213+
}
214+
if pr == nil {
215+
continue
216+
}
217+
218+
if pr.BaseRefName != expectedBase {
219+
if err := client.UpdatePRBase(r.prNumber, expectedBase); err != nil {
220+
cfg.Warningf("failed to update base branch for PR %s: %v",
221+
cfg.PRLink(r.prNumber, r.prURL), err)
222+
} else {
223+
cfg.Successf("Updated base branch for PR %s to %s",
224+
cfg.PRLink(r.prNumber, r.prURL), expectedBase)
225+
}
226+
}
227+
}
228+
return nil
229+
}
230+
231+
// upsertStack lists existing stacks and creates or updates as needed.
232+
func upsertStack(cfg *config.Config, client github.ClientOps, prNumbers []int) error {
54233
stacks, err := client.ListStacks()
55234
if err != nil {
56235
var httpErr *api.HTTPError
@@ -75,27 +254,6 @@ func runLink(cfg *config.Config, args []string) error {
75254
return updateLink(cfg, client, matchedStack, prNumbers)
76255
}
77256

78-
// parsePRNumbers converts string args to a validated list of PR numbers.
79-
// Returns an error if any arg is not a positive integer or if there are duplicates.
80-
func parsePRNumbers(args []string) ([]int, error) {
81-
prNumbers := make([]int, 0, len(args))
82-
seen := make(map[int]bool, len(args))
83-
84-
for _, arg := range args {
85-
n, err := strconv.Atoi(arg)
86-
if err != nil || n <= 0 {
87-
return nil, fmt.Errorf("invalid PR number: %q", arg)
88-
}
89-
if seen[n] {
90-
return nil, fmt.Errorf("duplicate PR number: %d", n)
91-
}
92-
seen[n] = true
93-
prNumbers = append(prNumbers, n)
94-
}
95-
96-
return prNumbers, nil
97-
}
98-
99257
// findMatchingStack finds a single stack that contains any of the given PR numbers.
100258
// Returns nil if no stack matches. Returns an error if PRs span multiple stacks.
101259
func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.RemoteStack, error) {

0 commit comments

Comments
 (0)