Skip to content

Commit d30cbfb

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

9 files changed

Lines changed: 389 additions & 17 deletions

File tree

cmd/link.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
109109
}
110110
}
111111

112+
// Look up the repository's PR template (best-effort; skip if not in a repo).
113+
var templateContent string
114+
if repoRoot, tlErr := git.RootDir(); tlErr == nil {
115+
templateContent = findPRTemplate(repoRoot)
116+
}
117+
112118
// Phase 4: Create PRs for branches that don't have one yet
113119
needsCreation := 0
114120
for _, r := range found {
@@ -119,7 +125,7 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error {
119125
if needsCreation > 0 {
120126
cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs"))
121127
}
122-
resolved, err := createMissingPRs(cfg, client, opts, args, found)
128+
resolved, err := createMissingPRs(cfg, client, opts, args, found, templateContent)
123129
if err != nil {
124130
return err
125131
}
@@ -307,7 +313,7 @@ func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNu
307313

308314
// createMissingPRs creates PRs for branches that don't have one yet.
309315
// 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) {
316+
func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg, templateContent string) ([]resolvedArg, error) {
311317
resolved := make([]resolvedArg, len(args))
312318

313319
for i, arg := range args {
@@ -323,7 +329,7 @@ func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOpt
323329
}
324330

325331
title := humanize(arg)
326-
body := generatePRBody("")
332+
body := generatePRBody("", templateContent)
327333

328334
newPR, err := client.CreatePR(baseBranch, arg, title, body, !opts.open)
329335
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/pr_template.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// prTemplatePaths lists the candidate locations for a pull request template.
10+
var prTemplatePaths = []string{
11+
".github/pull_request_template.md",
12+
".github/PULL_REQUEST_TEMPLATE.md",
13+
"pull_request_template.md",
14+
"PULL_REQUEST_TEMPLATE.md",
15+
"docs/pull_request_template.md",
16+
"docs/PULL_REQUEST_TEMPLATE.md",
17+
}
18+
19+
// findPRTemplate searches the repository root for a default pull request
20+
// template and returns its content. Returns an empty string if no template
21+
// is found or cannot be read.
22+
func findPRTemplate(repoRoot string) string {
23+
for _, candidate := range prTemplatePaths {
24+
path := filepath.Join(repoRoot, candidate)
25+
data, err := os.ReadFile(path)
26+
if err != nil {
27+
continue
28+
}
29+
content := strings.TrimSpace(string(data))
30+
if content != "" {
31+
return content
32+
}
33+
}
34+
return ""
35+
}

cmd/pr_template_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestFindPRTemplate_GitHubDir(t *testing.T) {
12+
root := t.TempDir()
13+
dir := filepath.Join(root, ".github")
14+
os.MkdirAll(dir, 0o755)
15+
os.WriteFile(filepath.Join(dir, "pull_request_template.md"), []byte("## Description\n\nFill in details."), 0o644)
16+
17+
got := findPRTemplate(root)
18+
assert.Equal(t, "## Description\n\nFill in details.", got)
19+
}
20+
21+
func TestFindPRTemplate_RootDir(t *testing.T) {
22+
root := t.TempDir()
23+
os.WriteFile(filepath.Join(root, "pull_request_template.md"), []byte("Root template"), 0o644)
24+
25+
got := findPRTemplate(root)
26+
assert.Equal(t, "Root template", got)
27+
}
28+
29+
func TestFindPRTemplate_DocsDir(t *testing.T) {
30+
root := t.TempDir()
31+
dir := filepath.Join(root, "docs")
32+
os.MkdirAll(dir, 0o755)
33+
os.WriteFile(filepath.Join(dir, "PULL_REQUEST_TEMPLATE.md"), []byte("Docs template"), 0o644)
34+
35+
got := findPRTemplate(root)
36+
assert.Equal(t, "Docs template", got)
37+
}
38+
39+
func TestFindPRTemplate_PriorityOrder(t *testing.T) {
40+
root := t.TempDir()
41+
// Create templates in multiple locations
42+
ghDir := filepath.Join(root, ".github")
43+
os.MkdirAll(ghDir, 0o755)
44+
os.WriteFile(filepath.Join(ghDir, "pull_request_template.md"), []byte("github template"), 0o644)
45+
os.WriteFile(filepath.Join(root, "pull_request_template.md"), []byte("root template"), 0o644)
46+
47+
// .github/ should win over root
48+
got := findPRTemplate(root)
49+
assert.Equal(t, "github template", got)
50+
}
51+
52+
func TestFindPRTemplate_NoTemplate(t *testing.T) {
53+
root := t.TempDir()
54+
55+
got := findPRTemplate(root)
56+
assert.Equal(t, "", got)
57+
}
58+
59+
func TestFindPRTemplate_EmptyFile(t *testing.T) {
60+
root := t.TempDir()
61+
os.WriteFile(filepath.Join(root, "pull_request_template.md"), []byte(" \n "), 0o644)
62+
63+
got := findPRTemplate(root)
64+
assert.Equal(t, "", got, "empty/whitespace-only template should be treated as no template")
65+
}
66+
67+
func TestFindPRTemplate_UpperCase(t *testing.T) {
68+
root := t.TempDir()
69+
os.WriteFile(filepath.Join(root, "PULL_REQUEST_TEMPLATE.md"), []byte("UPPER template"), 0o644)
70+
71+
got := findPRTemplate(root)
72+
assert.Equal(t, "UPPER template", got)
73+
}

cmd/submit.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
148148
cfg.Warningf("Failed to fetch branches from %s: %v", remote, err)
149149
}
150150

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

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

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

207213
if pr == nil {
208-
return createPR(cfg, client, s, i, baseBranch, opts)
214+
return createPR(cfg, client, s, i, baseBranch, opts, templateContent)
209215
}
210216

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

252258
// 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 {
259+
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error {
254260
b := s.Branches[i]
255261

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

277283
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open)
278284
if createErr != nil {
@@ -299,9 +305,14 @@ func defaultPRTitleBody(base, head string) (string, string) {
299305
return humanize(head), ""
300306
}
301307

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 {
308+
// generatePRBody builds a PR description. When a templateContent is provided,
309+
// it is used as the body and the attribution footer is omitted. Otherwise the
310+
// body is built from the commit body with a footer linking to the CLI.
311+
func generatePRBody(commitBody string, templateContent string) string {
312+
if templateContent != "" {
313+
return templateContent
314+
}
315+
305316
var parts []string
306317

307318
if commitBody != "" {

0 commit comments

Comments
 (0)