package cmd import ( "errors" "fmt" "strconv" "strings" "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) type submitOptions struct { auto bool draft bool remote string } func SubmitCmd(cfg *config.Config) *cobra.Command { opts := &submitOptions{} cmd := &cobra.Command{ Use: "submit", Short: "Create a stack of PRs on GitHub", RunE: func(cmd *cobra.Command, args []string) error { return runSubmit(cfg, opts) }, } cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting") cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)") return cmd } func runSubmit(cfg *config.Config, opts *submitOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") return ErrNotInStack } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) return ErrNotInStack } currentBranch, err := git.CurrentBranch() if err != nil { cfg.Errorf("failed to get current branch: %s", err) return ErrNotInStack } // Find the stack for the current branch without switching branches. // Submit should never change the user's checked-out branch. stacks := sf.FindAllStacksForBranch(currentBranch) if len(stacks) == 0 { cfg.Errorf("current branch %q is not part of a stack", currentBranch) return ErrNotInStack } if len(stacks) > 1 { cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch) return ErrDisambiguate } s := stacks[0] client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) return ErrAPIFailure } // Sync PR state to detect merged/queued PRs before pushing. syncStackPRs(cfg, s) // Push all active branches atomically remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } return ErrSilent } merged := s.MergedBranches() if len(merged) > 0 { cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) } queued := s.QueuedBranches() if len(queued) > 0 { cfg.Printf("Skipping %d queued %s", len(queued), plural(len(queued), "branch", "branches")) } activeBranches := activeBranchNames(s) if len(activeBranches) == 0 { cfg.Printf("All branches are merged or queued, nothing to submit") return nil } cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote) if err := git.Push(remote, activeBranches, true, true); err != nil { cfg.Errorf("failed to push: %s", err) return ErrSilent } // Create or update PRs — ensure every active branch has a PR with the // correct base branch. This makes submit idempotent: running it again // fills gaps and fixes base branches before syncing the stack. for i, b := range s.Branches { if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() { continue } baseBranch := s.ActiveBaseBranch(b.Branch) pr, err := client.FindPRForBranch(b.Branch) if err != nil { cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) continue } if pr == nil { // Create new PR — auto-generate title from commits/branch name, // then prompt interactively unless --auto or non-interactive. baseBranchForDiff := s.ActiveBaseBranch(b.Branch) title, commitBody := defaultPRTitleBody(baseBranchForDiff, b.Branch) originalTitle := title if !opts.auto && cfg.IsInteractive() { p := prompter.New(cfg.In, cfg.Out, cfg.Err) input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) if err != nil { if isInterruptError(err) { printInterrupt(cfg) return ErrSilent } // Non-interrupt error: keep the auto-generated title. } else if input != "" { title = input } } // If the user changed the title and the commit had a multi-line // message, put the full commit message in the PR body so no // content is lost. prBody := commitBody if title != originalTitle && commitBody != "" { prBody = originalTitle + "\n\n" + commitBody } body := generatePRBody(prBody) newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) if createErr != nil { cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) continue } cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) s.Branches[i].PullRequest = &stack.PullRequestRef{ Number: newPR.Number, ID: newPR.ID, URL: newPR.URL, } } else { // PR already exists — record it and fix base branch if needed. if s.Branches[i].PullRequest == nil { s.Branches[i].PullRequest = &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, URL: pr.URL, } } if pr.BaseRefName != baseBranch { if s.ID != "" { // PRs in an existing stack can't have their base updated // via the API — the stack owns the base relationships. cfg.Warningf("PR %s has base %q (expected %q) but cannot update while stacked", cfg.PRLink(pr.Number, pr.URL), pr.BaseRefName, baseBranch) } else { if err := client.UpdatePRBase(pr.Number, baseBranch); err != nil { cfg.Warningf("failed to update base branch for PR %s: %v", cfg.PRLink(pr.Number, pr.URL), err) } else { cfg.Successf("Updated base branch for PR %s to %s", cfg.PRLink(pr.Number, pr.URL), baseBranch) } } } else { cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) } } } // Create or update the stack on GitHub syncStack(cfg, client, s) // Update base commit hashes and sync PR state updateBaseSHAs(s) syncStackPRs(cfg, s) if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) } cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) return nil } // defaultPRTitleBody generates a PR title and body from the branch's commits. // If there is exactly one commit, use its subject as the title and its body // (if any) as the PR body. Otherwise, humanize the branch name for the title. func defaultPRTitleBody(base, head string) (string, string) { commits, err := git.LogRange(base, head) if err == nil && len(commits) == 1 { return commits[0].Subject, strings.TrimSpace(commits[0].Body) } return humanize(head), "" } // generatePRBody builds a PR description from the commit body (if any) // and a footer linking to the CLI and feedback form. func generatePRBody(commitBody string) string { var parts []string if commitBody != "" { parts = append(parts, commitBody) } footer := fmt.Sprintf( "Stack created with GitHub Stacks CLI • Give Feedback 💬", feedbackURL, ) parts = append(parts, footer) return strings.Join(parts, "\n\n---\n\n") } // humanize replaces hyphens and underscores with spaces. func humanize(s string) string { return strings.Map(func(r rune) rune { if r == '-' || r == '_' { return ' ' } return r }, s) } // syncStack creates or updates a stack on GitHub from the active PRs. // If the stack already exists (s.ID is set), it calls the PUT endpoint with // the full list of PRs to keep the remote stack in sync. If no stack exists // yet, it calls POST to create one. // This is a best-effort operation: failures are reported as warnings but do // not cause the submit command to fail (the PRs are already created). func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) { // Collect PR numbers in stack order (bottom to top). var prNumbers []int for _, b := range s.Branches { if b.IsMerged() { continue } if b.PullRequest != nil { prNumbers = append(prNumbers, b.PullRequest.Number) } } // The API requires at least 2 PRs to form a stack. if len(prNumbers) < 2 { return } if s.ID != "" { updateStack(cfg, client, s, prNumbers) } else { createNewStack(cfg, client, s, prNumbers) } } // updateStack calls the PUT endpoint to sync the full PR list for an existing stack. // If the remote stack was deleted (404), it clears the local ID and falls through // to createNewStack so the user doesn't need to re-run the command. func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) { if err := client.UpdateStack(s.ID, prNumbers); err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { switch httpErr.StatusCode { case 404: // Stack was deleted on GitHub — clear the stale ID and // immediately try to re-create it. s.ID = "" createNewStack(cfg, client, s, prNumbers) default: cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message) } } else { cfg.Warningf("Failed to update stack on GitHub: %v", err) } return } cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers)) } // createNewStack calls the POST endpoint to create a new stack, handling the // three types of 422 errors the API may return. func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) { stackID, err := client.CreateStack(prNumbers) if err == nil { s.ID = strconv.Itoa(stackID) cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers)) return } var httpErr *api.HTTPError if !errors.As(err, &httpErr) { cfg.Warningf("Failed to create stack on GitHub: %v", err) return } switch httpErr.StatusCode { case 422: handleCreate422(cfg, httpErr, prNumbers) case 404: cfg.Warningf("Stacked PRs are not enabled for this repository") default: cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message) } } // handleCreate422 handles 422 errors from the create stack endpoint. // The three known error messages are: // - "Stack must contain at least two pull requests" // - "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref" // - "Pull requests #123, #124, #125 are already stacked" func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) { msg := httpErr.Message if strings.Contains(msg, "already stacked") { // Check if the error lists exactly the same PRs we're trying to // stack. If so, they're already in a stack together — nothing to do. // If only a subset matches, the PRs are in a different stack. if allPRsInMessage(msg, prNumbers) { cfg.Successf("Stack with %d PRs is up to date", len(prNumbers)) return } cfg.Warningf("One or more PRs are already part of a different stack on GitHub") cfg.Printf(" To fix this, unstack the PRs from the web, then `%s`", cfg.ColorCyan("gh stack submit")) return } if strings.Contains(msg, "must form a stack") { cfg.Warningf("Cannot create stack: %s", msg) cfg.Printf(" Each PR's base branch must match the previous PR's head branch.") return } // "at least two" or any other validation error cfg.Warningf("Could not create stack: %s", msg) } // allPRsInMessage checks whether every PR number in prNumbers appears // in the error message (e.g. as "#65"). This distinguishes "our PRs are // already stacked together" from "some PRs are in a different stack." func allPRsInMessage(msg string, prNumbers []int) bool { for _, n := range prNumbers { if !strings.Contains(msg, fmt.Sprintf("#%d", n)) { return false } } return true }