Skip to content

Commit 4999852

Browse files
skarimCopilot
andauthored
parse PR URLs in args (#122)
* Accept PR URLs in link and checkout commands Add support for GitHub PR URLs (e.g. https://github.com/owner/repo/pull/42) as arguments to `gh stack link` and `gh stack checkout`, in addition to the existing PR number and branch name support. For `link`: PR URLs are parsed in findExistingPR before the numeric check. Unlike numeric args, if a URL-extracted PR number doesn't exist, the command errors immediately rather than falling through to branch name lookup (since a URL can never be a valid branch name). For `checkout`: PR URLs are parsed in runCheckout before the numeric check, routing to resolveNumericTarget which supports both local and remote API fallback — same behavior as passing a PR number directly. Closes #115 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * update docs --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a4485f5 commit 4999852

7 files changed

Lines changed: 250 additions & 16 deletions

File tree

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,13 @@ gh stack add -m "Refactor utils" cleanup-layer
165165

166166
### `gh stack checkout`
167167

168-
Check out a stack from a pull request number or branch name.
168+
Check out a stack from a pull request number, URL, or branch name.
169169

170170
```
171-
gh stack checkout [<pr-number> | <branch>]
171+
gh stack checkout [<pr-number> | <pr-url> | <branch>]
172172
```
173173

174-
When a PR number is provided (e.g. `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict.
174+
When a PR number or URL is provided (e.g. `123` or `https://github.com/owner/repo/pull/123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict.
175175

176176
When a branch name is provided, the command resolves it against locally tracked stacks only.
177177

@@ -183,6 +183,9 @@ When run without arguments in an interactive terminal, shows a menu of all local
183183
# Check out a stack by PR number
184184
gh stack checkout 42
185185

186+
# Check out a stack by PR URL
187+
gh stack checkout https://github.com/owner/repo/pull/42
188+
186189
# Check out a stack by branch name (local only)
187190
gh stack checkout feature-auth
188191

@@ -389,7 +392,7 @@ Link PRs into a stack on GitHub without local tracking.
389392
gh stack link [flags] <branch-or-pr> <branch-or-pr> [...]
390393
```
391394

392-
Creates or updates a stack on GitHub from branch names or PR numbers. This command does not store or modify any `gh stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
395+
Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not store or modify any `gh stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
393396

394397
Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically.
395398

@@ -410,6 +413,9 @@ gh stack link feature-auth feature-api feature-ui
410413
# Link existing PRs by number
411414
gh stack link 10 20 30
412415

416+
# Link existing PRs by URL
417+
gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20
418+
413419
# Add branches to an existing stack of PRs
414420
gh stack link 42 43 feature-auth feature-ui
415421

cmd/checkout.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command {
2323
opts := &checkoutOptions{}
2424

2525
cmd := &cobra.Command{
26-
Use: "checkout [<pr-number> | <branch>]",
27-
Short: "Checkout a stack from a PR number or branch name",
28-
Long: `Check out a stack from a pull request number or branch name.
26+
Use: "checkout [<pr-number> | <pr-url> | <branch>]",
27+
Short: "Checkout a stack from a PR number, PR URL, or branch name",
28+
Long: `Check out a stack from a pull request number, PR URL, or branch name.
2929
30-
When a PR number is provided (e.g. 123), the command first checks
30+
When a PR number or PR URL is provided (e.g. 123 or
31+
https://github.com/owner/repo/pull/123), the command first checks
3132
local tracking. If the PR is not tracked locally, it queries the
3233
GitHub API to discover the stack, fetches the branches, and sets up
3334
the stack locally. If the stack already exists locally and matches,
@@ -41,6 +42,9 @@ stacks to choose from.`,
4142
Example: ` # Check out a stack by PR number
4243
$ gh stack checkout 42
4344
45+
# Check out a stack by PR URL
46+
$ gh stack checkout https://github.com/owner/repo/pull/42
47+
4448
# Check out a stack by branch name
4549
$ gh stack checkout feat/api-routes
4650
@@ -91,6 +95,12 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
9195
return nil
9296
}
9397
targetBranch = s.Branches[len(s.Branches)-1].Branch
98+
} else if prNumber, ok := parsePRURL(opts.target); ok {
99+
// Target is a PR URL — extract number and resolve like a numeric target
100+
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
101+
if err != nil {
102+
return err
103+
}
94104
} else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 {
95105
// Target is a pure integer — try local PR, then remote API, then branch name
96106
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)

cmd/checkout_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,3 +930,86 @@ func TestFindRemoteStackForPR(t *testing.T) {
930930
require.NoError(t, err)
931931
assert.Nil(t, rs)
932932
}
933+
934+
func TestCheckout_ByPRURL_Local(t *testing.T) {
935+
// When a PR URL resolves to a locally tracked stack, no API call needed
936+
gitDir := t.TempDir()
937+
var checkedOut string
938+
restore := git.SetOps(&git.MockOps{
939+
GitDirFn: func() (string, error) { return gitDir, nil },
940+
CurrentBranchFn: func() (string, error) { return "main", nil },
941+
CheckoutBranchFn: func(name string) error {
942+
checkedOut = name
943+
return nil
944+
},
945+
})
946+
defer restore()
947+
948+
writeStackFile(t, gitDir, stack.Stack{
949+
Trunk: stack.BranchRef{Branch: "main"},
950+
Branches: []stack.BranchRef{
951+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}},
952+
},
953+
})
954+
955+
cfg, outR, errR := config.NewTestConfig()
956+
// No GitHubClientOverride — should resolve locally without API
957+
err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/42"})
958+
output := collectOutput(cfg, outR, errR)
959+
960+
require.NoError(t, err)
961+
assert.Equal(t, "b1", checkedOut)
962+
assert.Contains(t, output, "Switched to b1")
963+
}
964+
965+
func TestCheckout_ByPRURL_Remote(t *testing.T) {
966+
// When a PR URL is not tracked locally, fall back to remote API
967+
gitDir := t.TempDir()
968+
var checkedOut string
969+
970+
prDB := map[int]*github.PullRequest{
971+
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"},
972+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"},
973+
}
974+
975+
restore := git.SetOps(&git.MockOps{
976+
GitDirFn: func() (string, error) { return gitDir, nil },
977+
CurrentBranchFn: func() (string, error) { return "main", nil },
978+
BranchExistsFn: func(name string) bool { return name == "main" },
979+
FetchFn: func(string) error { return nil },
980+
CreateBranchFn: func(string, string) error { return nil },
981+
SetUpstreamTrackingFn: func(string, string) error { return nil },
982+
RevParseFn: func(string) (string, error) { return "abc123", nil },
983+
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
984+
CheckoutBranchFn: func(name string) error {
985+
checkedOut = name
986+
return nil
987+
},
988+
})
989+
defer restore()
990+
991+
// Empty stack file — nothing local
992+
writeStackFile(t, gitDir, stack.Stack{})
993+
994+
cfg, outR, errR := config.NewTestConfig()
995+
cfg.GitHubClientOverride = &github.MockClient{
996+
ListStacksFn: func() ([]github.RemoteStack, error) {
997+
return []github.RemoteStack{
998+
{ID: 1, PullRequests: []int{10, 11}},
999+
}, nil
1000+
},
1001+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1002+
if pr, ok := prDB[n]; ok {
1003+
return pr, nil
1004+
}
1005+
return nil, nil
1006+
},
1007+
}
1008+
1009+
err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/11"})
1010+
output := collectOutput(cfg, outR, errR)
1011+
1012+
require.NoError(t, err)
1013+
assert.Equal(t, "feat-2", checkedOut)
1014+
assert.Contains(t, output, "Imported stack with 2 branches")
1015+
}

cmd/link.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,19 @@ func LinkCmd(cfg *config.Config) *cobra.Command {
2525
cmd := &cobra.Command{
2626
Use: "link <branch-or-pr> <branch-or-pr> [<branch-or-pr>...]",
2727
Short: "Link PRs into a stack on GitHub without local tracking",
28-
Long: `Create or update a stack on GitHub from branch names or PR numbers.
28+
Long: `Create or update a stack on GitHub from branch names, PR numbers, or PR URLs.
2929
3030
This command does not rely on gh-stack local tracking state. It is
3131
designed for users who manage branches with external tools (e.g. jj,
3232
Sapling, ghstack, git-town, etc...) and want to use GitHub stacked
3333
PRs without adopting local stack tracking.
3434
3535
Arguments are provided in stack order (bottom to top). Each argument
36-
can be a branch name or a PR number. For numeric arguments, the
36+
can be a branch name, a PR number, or a PR URL (e.g.
37+
https://github.com/owner/repo/pull/123). For numeric arguments, the
3738
command first checks if a PR with that number exists; if not, it
38-
treats the argument as a branch name.
39+
treats the argument as a branch name. PR URLs are always resolved
40+
as pull requests (never as branch names).
3941
4042
Branch arguments are automatically pushed to the remote before
4143
creating or looking up PRs. For branches that already have open PRs,
@@ -51,6 +53,9 @@ the new PRs (existing PRs are never removed).`,
5153
# Link existing PRs by number
5254
$ gh stack link 41 42 43
5355
56+
# Link existing PRs by URL
57+
$ gh stack link https://github.com/owner/repo/pull/41 https://github.com/owner/repo/pull/42
58+
5459
# Specify a custom base branch for stack
5560
$ gh stack link --base develop auth-layer api-routes`,
5661
Args: cobra.MinimumNArgs(2),
@@ -233,6 +238,27 @@ func findExistingPRs(cfg *config.Config, client github.ClientOps, args []string)
233238
// findExistingPR looks up an existing PR for a single arg.
234239
// Returns nil if the arg is a branch with no open PR.
235240
func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*resolvedArg, error) {
241+
// If the arg is a PR URL, extract the number and look it up.
242+
// Unlike numeric args, a URL can never be a valid branch name,
243+
// so we error instead of falling through to branch lookup.
244+
if n, ok := parsePRURL(arg); ok {
245+
pr, err := client.FindPRByNumber(n)
246+
if err != nil {
247+
cfg.Errorf("failed to look up PR #%d: %v", n, err)
248+
return nil, ErrAPIFailure
249+
}
250+
if pr == nil {
251+
cfg.Errorf("PR #%d not found", n)
252+
return nil, ErrInvalidArgs
253+
}
254+
return &resolvedArg{
255+
branch: pr.HeadRefName,
256+
prNumber: pr.Number,
257+
prURL: pr.URL,
258+
pr: pr,
259+
}, nil
260+
}
261+
236262
// If numeric, try as PR number first
237263
if n, err := strconv.Atoi(arg); err == nil && n > 0 {
238264
pr, err := client.FindPRByNumber(n)

cmd/link_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,3 +1566,106 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
15661566
assert.NoError(t, err)
15671567
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
15681568
}
1569+
1570+
// --- PR URL tests ---
1571+
1572+
func TestLink_PRURLs_CreateNewStack(t *testing.T) {
1573+
var createdPRs []int
1574+
cfg, _, errR := config.NewTestConfig()
1575+
cfg.GitHubClientOverride = &github.MockClient{
1576+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1577+
return &github.PullRequest{
1578+
Number: n,
1579+
HeadRefName: fmt.Sprintf("branch-%d", n),
1580+
BaseRefName: "main",
1581+
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n),
1582+
}, nil
1583+
},
1584+
ListStacksFn: func() ([]github.RemoteStack, error) {
1585+
return []github.RemoteStack{}, nil
1586+
},
1587+
CreateStackFn: func(prNumbers []int) (int, error) {
1588+
createdPRs = prNumbers
1589+
return 42, nil
1590+
},
1591+
}
1592+
1593+
cmd := LinkCmd(cfg)
1594+
cmd.SetArgs([]string{
1595+
"https://github.com/o/r/pull/10",
1596+
"https://github.com/o/r/pull/20",
1597+
"https://github.com/o/r/pull/30",
1598+
})
1599+
cmd.SetOut(io.Discard)
1600+
cmd.SetErr(io.Discard)
1601+
err := cmd.Execute()
1602+
1603+
cfg.Err.Close()
1604+
errOut, _ := io.ReadAll(errR)
1605+
output := string(errOut)
1606+
1607+
assert.NoError(t, err)
1608+
assert.Equal(t, []int{10, 20, 30}, createdPRs)
1609+
assert.Contains(t, output, "Created stack with 3 PRs")
1610+
}
1611+
1612+
func TestLink_PRURLs_NotFound(t *testing.T) {
1613+
cfg, _, errR := config.NewTestConfig()
1614+
cfg.GitHubClientOverride = &github.MockClient{
1615+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1616+
return nil, nil // PR not found
1617+
},
1618+
}
1619+
1620+
cmd := LinkCmd(cfg)
1621+
cmd.SetArgs([]string{
1622+
"https://github.com/o/r/pull/999",
1623+
"https://github.com/o/r/pull/1000",
1624+
})
1625+
cmd.SetOut(io.Discard)
1626+
cmd.SetErr(io.Discard)
1627+
err := cmd.Execute()
1628+
1629+
cfg.Err.Close()
1630+
errOut, _ := io.ReadAll(errR)
1631+
output := string(errOut)
1632+
1633+
assert.ErrorIs(t, err, ErrInvalidArgs)
1634+
assert.Contains(t, output, "PR #999 not found")
1635+
}
1636+
1637+
func TestLink_MixedURLsAndNumbers(t *testing.T) {
1638+
var createdPRs []int
1639+
cfg, _, errR := config.NewTestConfig()
1640+
cfg.GitHubClientOverride = &github.MockClient{
1641+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1642+
return &github.PullRequest{
1643+
Number: n,
1644+
HeadRefName: fmt.Sprintf("branch-%d", n),
1645+
BaseRefName: "main",
1646+
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n),
1647+
}, nil
1648+
},
1649+
ListStacksFn: func() ([]github.RemoteStack, error) {
1650+
return []github.RemoteStack{}, nil
1651+
},
1652+
CreateStackFn: func(prNumbers []int) (int, error) {
1653+
createdPRs = prNumbers
1654+
return 42, nil
1655+
},
1656+
}
1657+
1658+
cmd := LinkCmd(cfg)
1659+
cmd.SetArgs([]string{"10", "https://github.com/o/r/pull/20", "30"})
1660+
cmd.SetOut(io.Discard)
1661+
cmd.SetErr(io.Discard)
1662+
err := cmd.Execute()
1663+
1664+
cfg.Err.Close()
1665+
errOut, _ := io.ReadAll(errR)
1666+
output := string(errOut)
1667+
1668+
assert.NoError(t, err)
1669+
assert.Equal(t, []int{10, 20, 30}, createdPRs)
1670+
assert.Contains(t, output, "Created stack with 3 PRs")
1671+
}

docs/src/content/docs/introduction/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han
9191
- **Syncing everything**`gh stack sync` fetches, rebases, pushes, and updates PR state in one command.
9292
- **Restructuring stacks**`gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack.
9393
- **Tearing down stacks**`gh stack unstack` removes a stack from GitHub and local tracking.
94-
- **Checking out a stack**`gh stack checkout <pr-number>` pulls down a stack, with all its branches, from GitHub to your local machine.
94+
- **Checking out a stack**`gh stack checkout <pr-number|url>` pulls down a stack, with all its branches, from GitHub to your local machine.
9595

9696
The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI.
9797

docs/src/content/docs/reference/cli.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,13 @@ gh stack view --json
135135

136136
### `gh stack checkout`
137137

138-
Check out a stack from a pull request number or branch name.
138+
Check out a stack from a pull request number, URL, or branch name.
139139

140140
```sh
141-
gh stack checkout [<pr-number> | <branch>]
141+
gh stack checkout [<pr-number> | <pr-url> | <branch>]
142142
```
143143

144-
When a PR number is provided (e.g., `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict.
144+
When a PR number or URL is provided (e.g., `123` or `https://github.com/owner/repo/pull/123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict.
145145

146146
When a branch name is provided, the command resolves it against locally tracked stacks only.
147147

@@ -153,6 +153,9 @@ When run without arguments in an interactive terminal, shows a menu of all local
153153
# Check out a stack by PR number
154154
gh stack checkout 42
155155

156+
# Check out a stack by PR URL
157+
gh stack checkout https://github.com/owner/repo/pull/42
158+
156159
# Check out a stack by branch name (local only)
157160
gh stack checkout feature-auth
158161

@@ -391,7 +394,7 @@ Link PRs into a stack on GitHub without local tracking.
391394
gh stack link [flags] <branch-or-pr> <branch-or-pr> [...]
392395
```
393396

394-
Creates or updates a stack on GitHub from branch names or PR numbers. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
397+
Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
395398

396399
Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically.
397400

@@ -412,6 +415,9 @@ gh stack link feature-auth feature-api feature-ui
412415
# Link existing PRs by number
413416
gh stack link 10 20 30
414417

418+
# Link existing PRs by URL
419+
gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20
420+
415421
# Add branches to an existing stack of PRs
416422
gh stack link 42 43 feature-auth feature-ui
417423

0 commit comments

Comments
 (0)