From fccf9ba1172e485fe5fb2fc771ea2809775f05d3 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 12 Jun 2026 02:43:46 -0400 Subject: [PATCH 1/2] Accept PR URLs in link and checkout commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- cmd/checkout.go | 18 ++++++-- cmd/checkout_test.go | 83 ++++++++++++++++++++++++++++++++++ cmd/link.go | 32 ++++++++++++-- cmd/link_test.go | 103 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 7 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index 413714b..fab8188 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -23,11 +23,12 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command { opts := &checkoutOptions{} cmd := &cobra.Command{ - Use: "checkout [ | ]", - Short: "Checkout a stack from a PR number or branch name", - Long: `Check out a stack from a pull request number or branch name. + Use: "checkout [ | | ]", + Short: "Checkout a stack from a PR number, PR URL, or branch name", + Long: `Check out a stack from a pull request number, PR URL, or branch name. -When a PR number is provided (e.g. 123), the command first checks +When a PR number or PR URL is provided (e.g. 123 or +https://github.com/owner/repo/pull/123), the command first checks local tracking. If the PR is not tracked locally, it queries the GitHub API to discover the stack, fetches the branches, and sets up the stack locally. If the stack already exists locally and matches, @@ -41,6 +42,9 @@ stacks to choose from.`, Example: ` # Check out a stack by PR number $ gh stack checkout 42 + # Check out a stack by PR URL + $ gh stack checkout https://github.com/owner/repo/pull/42 + # Check out a stack by branch name $ gh stack checkout feat/api-routes @@ -91,6 +95,12 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } targetBranch = s.Branches[len(s.Branches)-1].Branch + } else if prNumber, ok := parsePRURL(opts.target); ok { + // Target is a PR URL — extract number and resolve like a numeric target + s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) + if err != nil { + return err + } } else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 { // Target is a pure integer — try local PR, then remote API, then branch name s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 7c50c09..b6e2113 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -930,3 +930,86 @@ func TestFindRemoteStackForPR(t *testing.T) { require.NoError(t, err) assert.Nil(t, rs) } + +func TestCheckout_ByPRURL_Local(t *testing.T) { + // When a PR URL resolves to a locally tracked stack, no API call needed + gitDir := t.TempDir() + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + // No GitHubClientOverride — should resolve locally without API + err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/42"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b1", checkedOut) + assert.Contains(t, output, "Switched to b1") +} + +func TestCheckout_ByPRURL_Remote(t *testing.T) { + // When a PR URL is not tracked locally, fall back to remote API + gitDir := t.TempDir() + var checkedOut string + + prDB := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + } + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + SetUpstreamTrackingFn: func(string, string) error { return nil }, + RevParseFn: func(string) (string, error) { return "abc123", nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + // Empty stack file — nothing local + writeStackFile(t, gitDir, stack.Stack{}) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 1, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + if pr, ok := prDB[n]; ok { + return pr, nil + } + return nil, nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-2", checkedOut) + assert.Contains(t, output, "Imported stack with 2 branches") +} diff --git a/cmd/link.go b/cmd/link.go index 74018f4..e74e545 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -25,7 +25,7 @@ func LinkCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "link [...]", Short: "Link PRs into a stack on GitHub without local tracking", - Long: `Create or update a stack on GitHub from branch names or PR numbers. + Long: `Create or update a stack on GitHub from branch names, PR numbers, or PR URLs. This command does not rely on gh-stack local tracking state. It is designed for users who manage branches with external tools (e.g. jj, @@ -33,9 +33,11 @@ Sapling, ghstack, git-town, etc...) and want to use GitHub stacked PRs without adopting local stack tracking. Arguments are provided in stack order (bottom to top). Each argument -can be a branch name or a PR number. For numeric arguments, the +can be a branch name, a PR number, or a PR URL (e.g. +https://github.com/owner/repo/pull/123). For numeric arguments, the command first checks if a PR with that number exists; if not, it -treats the argument as a branch name. +treats the argument as a branch name. PR URLs are always resolved +as pull requests (never as branch names). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, @@ -51,6 +53,9 @@ the new PRs (existing PRs are never removed).`, # Link existing PRs by number $ gh stack link 41 42 43 + # Link existing PRs by URL + $ gh stack link https://github.com/owner/repo/pull/41 https://github.com/owner/repo/pull/42 + # Specify a custom base branch for stack $ gh stack link --base develop auth-layer api-routes`, Args: cobra.MinimumNArgs(2), @@ -233,6 +238,27 @@ func findExistingPRs(cfg *config.Config, client github.ClientOps, args []string) // findExistingPR looks up an existing PR for a single arg. // Returns nil if the arg is a branch with no open PR. func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*resolvedArg, error) { + // If the arg is a PR URL, extract the number and look it up. + // Unlike numeric args, a URL can never be a valid branch name, + // so we error instead of falling through to branch lookup. + if n, ok := parsePRURL(arg); ok { + pr, err := client.FindPRByNumber(n) + if err != nil { + cfg.Errorf("failed to look up PR #%d: %v", n, err) + return nil, ErrAPIFailure + } + if pr == nil { + cfg.Errorf("PR #%d not found", n) + return nil, ErrInvalidArgs + } + return &resolvedArg{ + branch: pr.HeadRefName, + prNumber: pr.Number, + prURL: pr.URL, + pr: pr, + }, nil + } + // If numeric, try as PR number first if n, err := strconv.Atoi(arg); err == nil && n > 0 { pr, err := client.FindPRByNumber(n) diff --git a/cmd/link_test.go b/cmd/link_test.go index 511fac0..b9adf84 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -1566,3 +1566,106 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) { assert.NoError(t, err) assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template") } + +// --- PR URL tests --- + +func TestLink_PRURLs_CreateNewStack(t *testing.T) { + var createdPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + createdPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{ + "https://github.com/o/r/pull/10", + "https://github.com/o/r/pull/20", + "https://github.com/o/r/pull/30", + }) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{10, 20, 30}, createdPRs) + assert.Contains(t, output, "Created stack with 3 PRs") +} + +func TestLink_PRURLs_NotFound(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return nil, nil // PR not found + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{ + "https://github.com/o/r/pull/999", + "https://github.com/o/r/pull/1000", + }) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "PR #999 not found") +} + +func TestLink_MixedURLsAndNumbers(t *testing.T) { + var createdPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + createdPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "https://github.com/o/r/pull/20", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{10, 20, 30}, createdPRs) + assert.Contains(t, output, "Created stack with 3 PRs") +} From 764d44828a800531ebfe7c4706413bac1b3c4572 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 12 Jun 2026 02:59:18 -0400 Subject: [PATCH 2/2] update docs --- README.md | 14 ++++++++++---- docs/src/content/docs/introduction/overview.md | 2 +- docs/src/content/docs/reference/cli.md | 14 ++++++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 270c3c6..cc20ec5 100644 --- a/README.md +++ b/README.md @@ -165,13 +165,13 @@ gh stack add -m "Refactor utils" cleanup-layer ### `gh stack checkout` -Check out a stack from a pull request number or branch name. +Check out a stack from a pull request number, URL, or branch name. ``` -gh stack checkout [ | ] +gh stack checkout [ | | ] ``` -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. +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. When a branch name is provided, the command resolves it against locally tracked stacks only. @@ -183,6 +183,9 @@ When run without arguments in an interactive terminal, shows a menu of all local # Check out a stack by PR number gh stack checkout 42 +# Check out a stack by PR URL +gh stack checkout https://github.com/owner/repo/pull/42 + # Check out a stack by branch name (local only) gh stack checkout feature-auth @@ -389,7 +392,7 @@ Link PRs into a stack on GitHub without local tracking. gh stack link [flags] [...] ``` -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. +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. 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. @@ -410,6 +413,9 @@ gh stack link feature-auth feature-api feature-ui # Link existing PRs by number gh stack link 10 20 30 +# Link existing PRs by URL +gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20 + # Add branches to an existing stack of PRs gh stack link 42 43 feature-auth feature-ui diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index 7ff1545..46a70e5 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -91,7 +91,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han - **Syncing everything** — `gh stack sync` fetches, rebases, pushes, and updates PR state in one command. - **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack. - **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking. -- **Checking out a stack** — `gh stack checkout ` pulls down a stack, with all its branches, from GitHub to your local machine. +- **Checking out a stack** — `gh stack checkout ` pulls down a stack, with all its branches, from GitHub to your local machine. 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. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 3990f69..dab458f 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -135,13 +135,13 @@ gh stack view --json ### `gh stack checkout` -Check out a stack from a pull request number or branch name. +Check out a stack from a pull request number, URL, or branch name. ```sh -gh stack checkout [ | ] +gh stack checkout [ | | ] ``` -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. +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. When a branch name is provided, the command resolves it against locally tracked stacks only. @@ -153,6 +153,9 @@ When run without arguments in an interactive terminal, shows a menu of all local # Check out a stack by PR number gh stack checkout 42 +# Check out a stack by PR URL +gh stack checkout https://github.com/owner/repo/pull/42 + # Check out a stack by branch name (local only) gh stack checkout feature-auth @@ -391,7 +394,7 @@ Link PRs into a stack on GitHub without local tracking. gh stack link [flags] [...] ``` -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. +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. 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. @@ -412,6 +415,9 @@ gh stack link feature-auth feature-api feature-ui # Link existing PRs by number gh stack link 10 20 30 +# Link existing PRs by URL +gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20 + # Add branches to an existing stack of PRs gh stack link 42 43 feature-auth feature-ui