Skip to content

Commit b71b10c

Browse files
authored
use PR template when opening PRs (#77)
* use pr template when opening prs * add a helper to clearly distinguish between filesystem errors and actual test failures
1 parent 1333040 commit b71b10c

9 files changed

Lines changed: 396 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: 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"
@@ -1203,3 +1205,104 @@ 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_BranchNames_UsesPRTemplate(t *testing.T) {
1210+
tmpDir := t.TempDir()
1211+
ghDir := filepath.Join(tmpDir, ".github")
1212+
require.NoError(t, os.MkdirAll(ghDir, 0o755))
1213+
require.NoError(t, os.WriteFile(
1214+
filepath.Join(ghDir, "pull_request_template.md"),
1215+
[]byte("## Summary\n\nDescribe your changes."),
1216+
0o644,
1217+
))
1218+
1219+
mock := newLinkGitMock("feat-a", "feat-b")
1220+
mock.RootDirFn = func() (string, error) { return tmpDir, nil }
1221+
restore := git.SetOps(mock)
1222+
defer restore()
1223+
1224+
var capturedBody string
1225+
cfg, _, errR := config.NewTestConfig()
1226+
cfg.GitHubClientOverride = &github.MockClient{
1227+
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
1228+
return nil, nil // No existing PRs
1229+
},
1230+
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
1231+
capturedBody = body
1232+
return &github.PullRequest{
1233+
Number: 1, HeadRefName: head, BaseRefName: base,
1234+
URL: "https://github.com/o/r/pull/1",
1235+
}, nil
1236+
},
1237+
ListStacksFn: func() ([]github.RemoteStack, error) {
1238+
return []github.RemoteStack{}, nil
1239+
},
1240+
CreateStackFn: func([]int) (int, error) { return 42, nil },
1241+
}
1242+
1243+
cmd := LinkCmd(cfg)
1244+
cmd.SetArgs([]string{"feat-a", "feat-b"})
1245+
cmd.SetOut(io.Discard)
1246+
cmd.SetErr(io.Discard)
1247+
err := cmd.Execute()
1248+
1249+
cfg.Err.Close()
1250+
_, _ = io.ReadAll(errR)
1251+
1252+
assert.NoError(t, err)
1253+
assert.Contains(t, capturedBody, "## Summary")
1254+
assert.Contains(t, capturedBody, "Describe your changes.")
1255+
assert.NotContains(t, capturedBody, "GitHub Stacks CLI", "footer should not be present when template is used")
1256+
}
1257+
1258+
func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
1259+
// When using PR numbers (no local repo context), no template is found
1260+
// and the footer should be present for newly created PRs.
1261+
mock := &git.MockOps{
1262+
RootDirFn: func() (string, error) {
1263+
return "", fmt.Errorf("not in a git repo")
1264+
},
1265+
}
1266+
restore := git.SetOps(mock)
1267+
defer restore()
1268+
1269+
var capturedBody string
1270+
cfg, _, errR := config.NewTestConfig()
1271+
cfg.GitHubClientOverride = &github.MockClient{
1272+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
1273+
if n == 10 {
1274+
return &github.PullRequest{
1275+
Number: 10, HeadRefName: "feat-a", BaseRefName: "main",
1276+
URL: "https://github.com/o/r/pull/10",
1277+
}, nil
1278+
}
1279+
return nil, nil // PR 20 doesn't exist → will create
1280+
},
1281+
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
1282+
return nil, nil
1283+
},
1284+
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
1285+
capturedBody = body
1286+
return &github.PullRequest{
1287+
Number: 20, HeadRefName: head, BaseRefName: base,
1288+
URL: "https://github.com/o/r/pull/20",
1289+
}, nil
1290+
},
1291+
ListStacksFn: func() ([]github.RemoteStack, error) {
1292+
return []github.RemoteStack{}, nil
1293+
},
1294+
CreateStackFn: func([]int) (int, error) { return 42, nil },
1295+
}
1296+
1297+
cmd := LinkCmd(cfg)
1298+
cmd.SetArgs([]string{"10", "20"})
1299+
cmd.SetOut(io.Discard)
1300+
cmd.SetErr(io.Discard)
1301+
err := cmd.Execute()
1302+
1303+
cfg.Err.Close()
1304+
_, _ = io.ReadAll(errR)
1305+
1306+
assert.NoError(t, err)
1307+
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
1308+
}

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)