Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions cmd/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"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/pr"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -109,6 +110,12 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
}
}

// Look up the repository's PR template (best-effort; skip if not in a repo).
var templateContent string
if repoRoot, tlErr := git.RootDir(); tlErr == nil {
templateContent = pr.FindTemplate(repoRoot)
}

// Phase 4: Create PRs for branches that don't have one yet
needsCreation := 0
for _, r := range found {
Expand All @@ -119,7 +126,7 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
if needsCreation > 0 {
cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs"))
}
resolved, err := createMissingPRs(cfg, client, opts, args, found)
resolved, err := createMissingPRs(cfg, client, opts, args, found, templateContent)
if err != nil {
return err
}
Expand Down Expand Up @@ -303,7 +310,7 @@ func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNu

// createMissingPRs creates PRs for branches that don't have one yet.
// Returns the fully resolved list with all branches mapped to PRs.
func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg) ([]resolvedArg, error) {
func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg, templateContent string) ([]resolvedArg, error) {
resolved := make([]resolvedArg, len(args))

for i, arg := range args {
Expand All @@ -319,7 +326,7 @@ func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOpt
}

title := humanize(arg)
body := generatePRBody("")
body := generatePRBody("", templateContent)

newPR, err := client.CreatePR(baseBranch, arg, title, body, !opts.open)
if err != nil {
Expand Down
103 changes: 103 additions & 0 deletions cmd/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
"testing"

"github.com/cli/go-gh/v2/pkg/api"
Expand Down Expand Up @@ -1203,3 +1205,104 @@ func TestLink_SkipsBaseFix_ForNewlyCreatedPRs(t *testing.T) {

// Silence "imported and not used" for fmt in case test helpers use it.
var _ = fmt.Sprintf

func TestLink_BranchNames_UsesPRTemplate(t *testing.T) {
tmpDir := t.TempDir()
ghDir := filepath.Join(tmpDir, ".github")
require.NoError(t, os.MkdirAll(ghDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(ghDir, "pull_request_template.md"),
[]byte("## Summary\n\nDescribe your changes."),
0o644,
))

mock := newLinkGitMock("feat-a", "feat-b")
mock.RootDirFn = func() (string, error) { return tmpDir, nil }
restore := git.SetOps(mock)
defer restore()

var capturedBody string
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
return nil, nil // No existing PRs
},
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
capturedBody = body
return &github.PullRequest{
Number: 1, HeadRefName: head, BaseRefName: base,
URL: "https://github.com/o/r/pull/1",
}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
}

cmd := LinkCmd(cfg)
cmd.SetArgs([]string{"feat-a", "feat-b"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
_, _ = io.ReadAll(errR)

assert.NoError(t, err)
assert.Contains(t, capturedBody, "## Summary")
assert.Contains(t, capturedBody, "Describe your changes.")
assert.NotContains(t, capturedBody, "GitHub Stacks CLI", "footer should not be present when template is used")
}

func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
// When using PR numbers (no local repo context), no template is found
// and the footer should be present for newly created PRs.
mock := &git.MockOps{
RootDirFn: func() (string, error) {
return "", fmt.Errorf("not in a git repo")
},
}
restore := git.SetOps(mock)
defer restore()

var capturedBody string
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
if n == 10 {
return &github.PullRequest{
Number: 10, HeadRefName: "feat-a", BaseRefName: "main",
URL: "https://github.com/o/r/pull/10",
}, nil
}
return nil, nil // PR 20 doesn't exist → will create
},
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
return nil, nil
},
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
capturedBody = body
return &github.PullRequest{
Number: 20, HeadRefName: head, BaseRefName: base,
URL: "https://github.com/o/r/pull/20",
}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
}

cmd := LinkCmd(cfg)
cmd.SetArgs([]string{"10", "20"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
_, _ = io.ReadAll(errR)

assert.NoError(t, err)
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
}
28 changes: 20 additions & 8 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/pr"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -148,6 +149,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// remote yet.
_ = git.FetchBranches(remote, activeBranches)

// Look up the repository's PR template once before creating any PRs.
var templateContent string
if repoRoot, err := git.RootDir(); err == nil {
templateContent = pr.FindTemplate(repoRoot)
}

// Push each branch and create/update its PR in stack order (bottom to top).
// Sequential pushing ensures each branch's base is up-to-date on the
// remote before the next branch is pushed, preventing race conditions.
Expand All @@ -165,7 +172,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {

// Find or create PR, and fix base if needed
baseBranch := s.ActiveBaseBranch(b.Branch)
if err := ensurePR(cfg, client, s, i, baseBranch, opts); err != nil {
if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent); err != nil {
if errors.Is(err, errInterrupt) {
printInterrupt(cfg)
return ErrSilent
Expand Down Expand Up @@ -195,7 +202,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// ensurePR finds or creates a PR for the branch at index i, and updates
// its base branch if needed. This is the single place where PR state is
// reconciled during submit.
func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions) error {
func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
b := s.Branches[i]

pr, err := client.FindPRForBranch(b.Branch)
Expand All @@ -205,7 +212,7 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
}

if pr == nil {
return createPR(cfg, client, s, i, baseBranch, opts)
return createPR(cfg, client, s, i, baseBranch, opts, templateContent)
}

// PR exists — record it and fix base if needed.
Expand Down Expand Up @@ -250,7 +257,7 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
}

// createPR creates a new PR for the branch at index i.
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions) error {
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
b := s.Branches[i]

title, commitBody := defaultPRTitleBody(baseBranch, b.Branch)
Expand All @@ -272,7 +279,7 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
if title != originalTitle && commitBody != "" {
prBody = originalTitle + "\n\n" + commitBody
}
body := generatePRBody(prBody)
body := generatePRBody(prBody, templateContent)

newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open)
if createErr != nil {
Expand All @@ -299,9 +306,14 @@ func defaultPRTitleBody(base, head string) (string, string) {
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 {
// generatePRBody builds a PR description. When a templateContent is provided,
// it is used as the body and the attribution footer is omitted. Otherwise the
// body is built from the commit body with a footer linking to the CLI.
func generatePRBody(commitBody string, templateContent string) string {
if templateContent != "" {
return templateContent
}

var parts []string

if commitBody != "" {
Expand Down
Loading
Loading