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/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") +} 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