Skip to content

Commit d5ba3de

Browse files
committed
Add template support to issue create, pr create
If multiple templates are found, the user is prompted to select one. The templates are searched for, in order of preference: - issues: 1. `.github/ISSUE_TEMPLATE/*.md` 2. `.github/ISSUE_TEMPLATE.md` 3. `ISSUE_TEMPLATE/*.md` 4. `ISSUE_TEMPLATE.md` 5. `docs/ISSUE_TEMPLATE/*.md` 6. `docs/ISSUE_TEMPLATE.md` - pull requests: 1. `.github/PULL_REQUEST_TEMPLATE/*.md` 2. `.github/PULL_REQUEST_TEMPLATE.md` 3. `PULL_REQUEST_TEMPLATE/*.md` 4. `PULL_REQUEST_TEMPLATE.md` 5. `docs/PULL_REQUEST_TEMPLATE/*.md` 6. `docs/PULL_REQUEST_TEMPLATE.md` The filename matches are case-insensitive.
1 parent 2c94616 commit d5ba3de

File tree

6 files changed

+418
-6
lines changed

6 files changed

+418
-6
lines changed

command/issue.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package command
33
import (
44
"fmt"
55
"io"
6-
"os"
76
"regexp"
87
"strconv"
98
"strings"
109

1110
"github.com/github/gh-cli/api"
1211
"github.com/github/gh-cli/context"
12+
"github.com/github/gh-cli/git"
13+
"github.com/github/gh-cli/pkg/githubtemplate"
1314
"github.com/github/gh-cli/utils"
1415
"github.com/pkg/errors"
1516
"github.com/spf13/cobra"
@@ -241,11 +242,16 @@ func issueCreate(cmd *cobra.Command, args []string) error {
241242
return err
242243
}
243244

245+
var templateFiles []string
246+
if rootDir, err := git.ToplevelDir(); err == nil {
247+
// TODO: figure out how to stub this in tests
248+
templateFiles = githubtemplate.Find(rootDir, "ISSUE_TEMPLATE")
249+
}
250+
244251
if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
245252
// TODO: move URL generation into GitHubRepository
246253
openURL := fmt.Sprintf("https://github.com/%s/%s/issues/new", baseRepo.RepoOwner(), baseRepo.RepoName())
247-
// TODO: figure out how to stub this in tests
248-
if stat, err := os.Stat(".github/ISSUE_TEMPLATE"); err == nil && stat.IsDir() {
254+
if len(templateFiles) > 1 {
249255
openURL += "/choose"
250256
}
251257
cmd.Printf("Opening %s in your browser.\n", openURL)
@@ -269,7 +275,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
269275
interactive := title == "" || body == ""
270276

271277
if interactive {
272-
tb, err := titleBodySurvey(cmd, title, body)
278+
tb, err := titleBodySurvey(cmd, title, body, templateFiles)
273279
if err != nil {
274280
return errors.Wrap(err, "could not collect title and/or body")
275281
}

command/pr_create.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/github/gh-cli/api"
99
"github.com/github/gh-cli/context"
1010
"github.com/github/gh-cli/git"
11+
"github.com/github/gh-cli/pkg/githubtemplate"
1112
"github.com/github/gh-cli/utils"
1213
"github.com/pkg/errors"
1314
"github.com/spf13/cobra"
@@ -71,7 +72,13 @@ func prCreate(cmd *cobra.Command, _ []string) error {
7172
interactive := title == "" || body == ""
7273

7374
if interactive {
74-
tb, err := titleBodySurvey(cmd, title, body)
75+
var templateFiles []string
76+
if rootDir, err := git.ToplevelDir(); err == nil {
77+
// TODO: figure out how to stub this in tests
78+
templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE")
79+
}
80+
81+
tb, err := titleBodySurvey(cmd, title, body, templateFiles)
7582
if err != nil {
7683
return errors.Wrap(err, "could not collect title and/or body")
7784
}

command/title_body_survey.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package command
22

33
import (
44
"fmt"
5+
56
"github.com/AlecAivazis/survey/v2"
7+
"github.com/github/gh-cli/pkg/githubtemplate"
68
"github.com/pkg/errors"
79
"github.com/spf13/cobra"
810
)
@@ -44,9 +46,45 @@ func confirm() (int, error) {
4446
return confirmAnswers.Confirmation, nil
4547
}
4648

47-
func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string) (*titleBody, error) {
49+
func selectTemplate(templatePaths []string) (string, error) {
50+
templateResponse := struct {
51+
Index int
52+
}{}
53+
if len(templatePaths) > 1 {
54+
templateNames := []string{}
55+
for _, p := range templatePaths {
56+
templateNames = append(templateNames, githubtemplate.ExtractName(p))
57+
}
58+
59+
selectQs := []*survey.Question{
60+
{
61+
Name: "index",
62+
Prompt: &survey.Select{
63+
Message: "Choose a template",
64+
Options: templateNames,
65+
},
66+
},
67+
}
68+
if err := survey.Ask(selectQs, &templateResponse); err != nil {
69+
return "", errors.Wrap(err, "could not prompt")
70+
}
71+
}
72+
73+
templateContents := githubtemplate.ExtractContents(templatePaths[templateResponse.Index])
74+
return string(templateContents), nil
75+
}
76+
77+
func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string, templatePaths []string) (*titleBody, error) {
4878
inProgress := titleBody{}
4979

80+
if providedBody == "" && len(templatePaths) > 0 {
81+
templateContents, err := selectTemplate(templatePaths)
82+
if err != nil {
83+
return nil, err
84+
}
85+
inProgress.Body = templateContents
86+
}
87+
5088
confirmed := false
5189
editor := determineEditor()
5290

@@ -64,6 +102,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody stri
64102
Message: fmt.Sprintf("Body (%s)", editor),
65103
FileName: "*.md",
66104
Default: inProgress.Body,
105+
HideDefault: true,
67106
AppendDefault: true,
68107
Editor: editor,
69108
},

git/git.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
151151
return
152152
}
153153

154+
// ToplevelDir returns the top-level directory path of the current repository
155+
func ToplevelDir() (string, error) {
156+
showCmd := exec.Command("git", "rev-parse", "--show-toplevel")
157+
output, err := utils.PrepareCmd(showCmd).Output()
158+
return firstLine(output), err
159+
160+
}
161+
154162
func outputLines(output []byte) []string {
155163
lines := strings.TrimSuffix(string(output), "\n")
156164
return strings.Split(lines, "\n")
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package githubtemplate
2+
3+
import (
4+
"io/ioutil"
5+
"path"
6+
"regexp"
7+
"sort"
8+
"strings"
9+
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
// Find returns the list of template file paths
14+
func Find(rootDir string, name string) []string {
15+
results := []string{}
16+
17+
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
18+
candidateDirs := []string{
19+
path.Join(rootDir, ".github"),
20+
rootDir,
21+
path.Join(rootDir, "docs"),
22+
}
23+
24+
for _, dir := range candidateDirs {
25+
files, err := ioutil.ReadDir(dir)
26+
if err != nil {
27+
continue
28+
}
29+
30+
// detect multiple templates in a subdirectory
31+
for _, file := range files {
32+
if strings.EqualFold(file.Name(), name) && file.IsDir() {
33+
templates, err := ioutil.ReadDir(path.Join(dir, file.Name()))
34+
if err != nil {
35+
break
36+
}
37+
for _, tf := range templates {
38+
if strings.HasSuffix(tf.Name(), ".md") {
39+
results = append(results, path.Join(dir, file.Name(), tf.Name()))
40+
}
41+
}
42+
if len(results) > 0 {
43+
goto done
44+
}
45+
break
46+
}
47+
}
48+
49+
// detect a single template file
50+
for _, file := range files {
51+
if strings.EqualFold(file.Name(), name+".md") {
52+
results = append(results, path.Join(dir, file.Name()))
53+
break
54+
}
55+
}
56+
if len(results) > 0 {
57+
goto done
58+
}
59+
}
60+
61+
done:
62+
sort.Sort(sort.StringSlice(results))
63+
return results
64+
}
65+
66+
// ExtractName returns the name of the template from YAML front-matter
67+
func ExtractName(filePath string) string {
68+
contents, err := ioutil.ReadFile(filePath)
69+
if err == nil && detectFrontmatter(contents)[0] == 0 {
70+
templateData := struct {
71+
Name string
72+
}{}
73+
if err := yaml.Unmarshal(contents, &templateData); err == nil && templateData.Name != "" {
74+
return templateData.Name
75+
}
76+
}
77+
return path.Base(filePath)
78+
}
79+
80+
// ExtractContents returns the template contents without the YAML front-matter
81+
func ExtractContents(filePath string) []byte {
82+
contents, err := ioutil.ReadFile(filePath)
83+
if err != nil {
84+
return []byte{}
85+
}
86+
if frontmatterBoundaries := detectFrontmatter(contents); frontmatterBoundaries[0] == 0 {
87+
return contents[frontmatterBoundaries[1]:]
88+
}
89+
return contents
90+
}
91+
92+
var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`)
93+
94+
func detectFrontmatter(c []byte) []int {
95+
if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 {
96+
return []int{matches[0][0], matches[1][1]}
97+
}
98+
return []int{-1, -1}
99+
}

0 commit comments

Comments
 (0)