Skip to content

Commit 63439c6

Browse files
committed
use pr template when opening prs
1 parent 2b82402 commit 63439c6

9 files changed

Lines changed: 445 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
}
@@ -303,7 +310,7 @@ func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNu
303310

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

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

321328
title := humanize(arg)
322-
body := generatePRBody("")
329+
body := generatePRBody("", templateContent)
323330

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

cmd/link_test.go

Lines changed: 159 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"
@@ -1203,3 +1205,160 @@ func TestLink_SkipsBaseFix_ForNewlyCreatedPRs(t *testing.T) {
12031205

12041206
// Silence "imported and not used" for fmt in case test helpers use it.
12051207
var _ = fmt.Sprintf
1208+
1209+
func TestLink_FetchesBeforePush(t *testing.T) {
1210+
var callOrder []string
1211+
var fetchedBranches []string
1212+
1213+
mock := newLinkGitMock("feat-a", "feat-b")
1214+
mock.FetchBranchesFn = func(remote string, branches []string) error {
1215+
callOrder = append(callOrder, "fetch")
1216+
fetchedBranches = branches
1217+
assert.Equal(t, "origin", remote)
1218+
return nil
1219+
}
1220+
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
1221+
callOrder = append(callOrder, "push")
1222+
return nil
1223+
}
1224+
1225+
restore := git.SetOps(mock)
1226+
defer restore()
1227+
1228+
prNum := 0
1229+
cfg, _, errR := config.NewTestConfig()
1230+
cfg.GitHubClientOverride = &github.MockClient{
1231+
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
1232+
prNum++
1233+
return &github.PullRequest{
1234+
Number: prNum,
1235+
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prNum),
1236+
BaseRefName: "main",
1237+
HeadRefName: branch,
1238+
State: "OPEN",
1239+
}, nil
1240+
},
1241+
ListStacksFn: func() ([]github.RemoteStack, error) {
1242+
return []github.RemoteStack{}, nil
1243+
},
1244+
CreateStackFn: func(prNumbers []int) (int, error) {
1245+
return 42, nil
1246+
},
1247+
}
1248+
1249+
cmd := LinkCmd(cfg)
1250+
cmd.SetArgs([]string{"feat-a", "feat-b"})
1251+
cmd.SetOut(io.Discard)
1252+
cmd.SetErr(io.Discard)
1253+
err := cmd.Execute()
1254+
1255+
cfg.Err.Close()
1256+
_, _ = io.ReadAll(errR)
1257+
1258+
assert.NoError(t, err)
1259+
assert.Equal(t, []string{"feat-a", "feat-b"}, fetchedBranches, "should fetch pushed branches")
1260+
require.Len(t, callOrder, 2)
1261+
assert.Equal(t, "fetch", callOrder[0], "fetch must happen before push")
1262+
assert.Equal(t, "push", callOrder[1])
1263+
}
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
// remote yet.
149150
_ = git.FetchBranches(remote, activeBranches)
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)