@@ -11,36 +11,57 @@ import (
1111 "github.com/spf13/cobra"
1212)
1313
14+ type linkOptions struct {
15+ base string
16+ draft bool
17+ }
18+
1419func 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
2027This command works entirely via the GitHub API and does not modify
2128any local state. It is designed for users who manage branches with
2229external tools (e.g. jj) and want to use GitHub stacked PRs without
2330adopting 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
2941If the PRs are not yet in a stack, a new stack is created. If some of
3042the PRs are already in a stack, the existing stack is updated to include
3143the 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.
101259func findMatchingStack (stacks []github.RemoteStack , prNumbers []int ) (* github.RemoteStack , error ) {
0 commit comments