Skip to content

Commit f14e36a

Browse files
committed
use pr template when opening prs
1 parent c4d183c commit f14e36a

9 files changed

Lines changed: 389 additions & 17 deletions

File tree

cmd/link.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/github/gh-stack/internal/config"
1010
"github.com/github/gh-stack/internal/git"
1111
"github.com/github/gh-stack/internal/github"
12+
"github.com/github/gh-stack/internal/pr"
1213
"github.com/spf13/cobra"
1314
)
1415

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

113+
// Look up the repository's PR template (best-effort; skip if not in a repo).
114+
var templateContent string
115+
if repoRoot, tlErr := git.RootDir(); tlErr == nil {
116+
templateContent = pr.FindTemplate(repoRoot)
117+
}
118+
112119
// Phase 4: Create PRs for branches that don't have one yet
113120
needsCreation := 0
114121
for _, r := range found {
@@ -119,7 +126,7 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
119126
if needsCreation > 0 {
120127
cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs"))
121128
}
122-
resolved, err := createMissingPRs(cfg, client, opts, args, found)
129+
resolved, err := createMissingPRs(cfg, client, opts, args, found, templateContent)
123130
if err != nil {
124131
return err
125132
}
@@ -307,7 +314,7 @@ func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNu
307314

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

313320
for i, arg := range args {
@@ -323,7 +330,7 @@ func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOpt
323330
}
324331

325332
title := humanize(arg)
326-
body := generatePRBody("")
333+
body := generatePRBody("", templateContent)
327334

328335
newPR, err := client.CreatePR(baseBranch, arg, title, body, !opts.open)
329336
if err != nil {

cmd/link_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package cmd
33
import (
44
"fmt"
55
"io"
6+
"os"
7+
"path/filepath"
68
"testing"
79

810
"github.com/cli/go-gh/v2/pkg/api"
@@ -1259,3 +1261,104 @@ func TestLink_FetchesBeforePush(t *testing.T) {
12591261
assert.Equal(t, "fetch", callOrder[0], "fetch must happen before push")
12601262
assert.Equal(t, "push", callOrder[1])
12611263
}
1264+
1265+
func TestLink_BranchNames_UsesPRTemplate(t *testing.T) {
1266+
tmpDir := t.TempDir()
1267+
ghDir := filepath.Join(tmpDir, ".github")
1268+
require.NoError(t, os.MkdirAll(ghDir, 0o755))
1269+
require.NoError(t, os.WriteFile(
1270+
filepath.Join(ghDir, "pull_request_template.md"),
1271+
[]byte("## Summary\n\nDescribe your changes."),
1272+
0o644,
1273+
))
1274+
1275+
mock := newLinkGitMock("feat-a", "feat-b")
1276+
mock.RootDirFn = func() (string, error) { return tmpDir, nil }
1277+
restore := git.SetOps(mock)
1278+
defer restore()
1279+
1280+
var capturedBody string
1281+
cfg, _, errR := config.NewTestConfig()
1282+
cfg.GitHubClientOverride = &github.MockClient{
1283+
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
1284+
return nil, nil // No existing PRs
1285+
},
1286+
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
1287+
capturedBody = body
1288+
return &github.PullRequest{
1289+
Number: 1, HeadRefName: head, BaseRefName: base,
1290+
URL: "https://github.com/o/r/pull/1",
1291+
}, nil
1292+
},
1293+
ListStacksFn: func() ([]github.RemoteStack, error) {
1294+
return []github.RemoteStack{}, nil
1295+
},
1296+
CreateStackFn: func([]int) (int, error) { return 42, nil },
1297+
}
1298+
1299+
cmd := LinkCmd(cfg)
1300+
cmd.SetArgs([]string{"feat-a", "feat-b"})
1301+
cmd.SetOut(io.Discard)
1302+
cmd.SetErr(io.Discard)
1303+
err := cmd.Execute()
1304+
1305+
cfg.Err.Close()
1306+
_, _ = io.ReadAll(errR)
1307+
1308+
assert.NoError(t, err)
1309+
assert.Contains(t, capturedBody, "## Summary")
1310+
assert.Contains(t, capturedBody, "Describe your changes.")
1311+
assert.NotContains(t, capturedBody, "GitHub Stacks CLI", "footer should not be present when template is used")
1312+
}
1313+
1314+
func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
1315+
// When using PR numbers (no local repo context), no template is found
1316+
// and the footer should be present for newly created PRs.
1317+
mock := &git.MockOps{
1318+
RootDirFn: func() (string, error) {
1319+
return "", fmt.Errorf("not in a git repo")
1320+
},
1321+
}
1322+
restore := git.SetOps(mock)
1323+
defer restore()
1324+
1325+
var capturedBody string
1326+
cfg, _, errR := config.NewTestConfig()
1327+
cfg.GitHubClientOverride = &github.MockClient{
1328+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1329+
if n == 10 {
1330+
return &github.PullRequest{
1331+
Number: 10, HeadRefName: "feat-a", BaseRefName: "main",
1332+
URL: "https://github.com/o/r/pull/10",
1333+
}, nil
1334+
}
1335+
return nil, nil // PR 20 doesn't exist → will create
1336+
},
1337+
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
1338+
return nil, nil
1339+
},
1340+
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
1341+
capturedBody = body
1342+
return &github.PullRequest{
1343+
Number: 20, HeadRefName: head, BaseRefName: base,
1344+
URL: "https://github.com/o/r/pull/20",
1345+
}, nil
1346+
},
1347+
ListStacksFn: func() ([]github.RemoteStack, error) {
1348+
return []github.RemoteStack{}, nil
1349+
},
1350+
CreateStackFn: func([]int) (int, error) { return 42, nil },
1351+
}
1352+
1353+
cmd := LinkCmd(cfg)
1354+
cmd.SetArgs([]string{"10", "20"})
1355+
cmd.SetOut(io.Discard)
1356+
cmd.SetErr(io.Discard)
1357+
err := cmd.Execute()
1358+
1359+
cfg.Err.Close()
1360+
_, _ = io.ReadAll(errR)
1361+
1362+
assert.NoError(t, err)
1363+
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
1364+
}

cmd/submit.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/github/gh-stack/internal/git"
1313
"github.com/github/gh-stack/internal/github"
1414
"github.com/github/gh-stack/internal/modify"
15+
"github.com/github/gh-stack/internal/pr"
1516
"github.com/github/gh-stack/internal/stack"
1617
"github.com/spf13/cobra"
1718
)
@@ -148,6 +149,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
148149
cfg.Warningf("Failed to fetch branches from %s: %v", remote, err)
149150
}
150151

152+
// Look up the repository's PR template once before creating any PRs.
153+
var templateContent string
154+
if repoRoot, err := git.RootDir(); err == nil {
155+
templateContent = pr.FindTemplate(repoRoot)
156+
}
157+
151158
// Push each branch and create/update its PR in stack order (bottom to top).
152159
// Sequential pushing ensures each branch's base is up-to-date on the
153160
// remote before the next branch is pushed, preventing race conditions.
@@ -165,7 +172,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
165172

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

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

207214
if pr == nil {
208-
return createPR(cfg, client, s, i, baseBranch, opts)
215+
return createPR(cfg, client, s, i, baseBranch, opts, templateContent)
209216
}
210217

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

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

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

277284
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open)
278285
if createErr != nil {
@@ -299,9 +306,14 @@ func defaultPRTitleBody(base, head string) (string, string) {
299306
return humanize(head), ""
300307
}
301308

302-
// generatePRBody builds a PR description from the commit body (if any)
303-
// and a footer linking to the CLI and feedback form.
304-
func generatePRBody(commitBody string) string {
309+
// generatePRBody builds a PR description. When a templateContent is provided,
310+
// it is used as the body and the attribution footer is omitted. Otherwise the
311+
// body is built from the commit body with a footer linking to the CLI.
312+
func generatePRBody(commitBody string, templateContent string) string {
313+
if templateContent != "" {
314+
return templateContent
315+
}
316+
305317
var parts []string
306318

307319
if commitBody != "" {

0 commit comments

Comments
 (0)