Skip to content

Commit

Permalink
Add template support to issue create, pr create
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mislav committed Dec 18, 2019
1 parent 2c94616 commit d5ba3de
Show file tree
Hide file tree
Showing 6 changed files with 418 additions and 6 deletions.
14 changes: 10 additions & 4 deletions command/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package command
import (
"fmt"
"io"
"os"
"regexp"
"strconv"
"strings"

"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/git"
"github.com/github/gh-cli/pkg/githubtemplate"
"github.com/github/gh-cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -241,11 +242,16 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return err
}

var templateFiles []string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "ISSUE_TEMPLATE")
}

if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
// TODO: move URL generation into GitHubRepository
openURL := fmt.Sprintf("https://github.com/%s/%s/issues/new", baseRepo.RepoOwner(), baseRepo.RepoName())
// TODO: figure out how to stub this in tests
if stat, err := os.Stat(".github/ISSUE_TEMPLATE"); err == nil && stat.IsDir() {
if len(templateFiles) > 1 {
openURL += "/choose"
}
cmd.Printf("Opening %s in your browser.\n", openURL)
Expand All @@ -269,7 +275,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
interactive := title == "" || body == ""

if interactive {
tb, err := titleBodySurvey(cmd, title, body)
tb, err := titleBodySurvey(cmd, title, body, templateFiles)
if err != nil {
return errors.Wrap(err, "could not collect title and/or body")
}
Expand Down
9 changes: 8 additions & 1 deletion command/pr_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/git"
"github.com/github/gh-cli/pkg/githubtemplate"
"github.com/github/gh-cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -71,7 +72,13 @@ func prCreate(cmd *cobra.Command, _ []string) error {
interactive := title == "" || body == ""

if interactive {
tb, err := titleBodySurvey(cmd, title, body)
var templateFiles []string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE")
}

tb, err := titleBodySurvey(cmd, title, body, templateFiles)
if err != nil {
return errors.Wrap(err, "could not collect title and/or body")
}
Expand Down
41 changes: 40 additions & 1 deletion command/title_body_survey.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package command

import (
"fmt"

"github.com/AlecAivazis/survey/v2"
"github.com/github/gh-cli/pkg/githubtemplate"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -44,9 +46,45 @@ func confirm() (int, error) {
return confirmAnswers.Confirmation, nil
}

func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string) (*titleBody, error) {
func selectTemplate(templatePaths []string) (string, error) {
templateResponse := struct {
Index int
}{}
if len(templatePaths) > 1 {
templateNames := []string{}
for _, p := range templatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}

selectQs := []*survey.Question{
{
Name: "index",
Prompt: &survey.Select{
Message: "Choose a template",
Options: templateNames,
},
},
}
if err := survey.Ask(selectQs, &templateResponse); err != nil {
return "", errors.Wrap(err, "could not prompt")
}
}

templateContents := githubtemplate.ExtractContents(templatePaths[templateResponse.Index])
return string(templateContents), nil
}

func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string, templatePaths []string) (*titleBody, error) {
inProgress := titleBody{}

if providedBody == "" && len(templatePaths) > 0 {
templateContents, err := selectTemplate(templatePaths)
if err != nil {
return nil, err
}
inProgress.Body = templateContents
}

confirmed := false
editor := determineEditor()

Expand All @@ -64,6 +102,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody stri
Message: fmt.Sprintf("Body (%s)", editor),
FileName: "*.md",
Default: inProgress.Body,
HideDefault: true,
AppendDefault: true,
Editor: editor,
},
Expand Down
8 changes: 8 additions & 0 deletions git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
return
}

// ToplevelDir returns the top-level directory path of the current repository
func ToplevelDir() (string, error) {
showCmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := utils.PrepareCmd(showCmd).Output()
return firstLine(output), err

}

func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
Expand Down
99 changes: 99 additions & 0 deletions pkg/githubtemplate/github_template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package githubtemplate

import (
"io/ioutil"
"path"
"regexp"
"sort"
"strings"

"gopkg.in/yaml.v3"
)

// Find returns the list of template file paths
func Find(rootDir string, name string) []string {
results := []string{}

// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
candidateDirs := []string{
path.Join(rootDir, ".github"),
rootDir,
path.Join(rootDir, "docs"),
}

for _, dir := range candidateDirs {
files, err := ioutil.ReadDir(dir)
if err != nil {
continue
}

// detect multiple templates in a subdirectory
for _, file := range files {
if strings.EqualFold(file.Name(), name) && file.IsDir() {
templates, err := ioutil.ReadDir(path.Join(dir, file.Name()))
if err != nil {
break
}
for _, tf := range templates {
if strings.HasSuffix(tf.Name(), ".md") {
results = append(results, path.Join(dir, file.Name(), tf.Name()))
}
}
if len(results) > 0 {
goto done
}
break
}
}

// detect a single template file
for _, file := range files {
if strings.EqualFold(file.Name(), name+".md") {
results = append(results, path.Join(dir, file.Name()))
break
}
}
if len(results) > 0 {
goto done
}
}

done:
sort.Sort(sort.StringSlice(results))
return results
}

// ExtractName returns the name of the template from YAML front-matter
func ExtractName(filePath string) string {
contents, err := ioutil.ReadFile(filePath)
if err == nil && detectFrontmatter(contents)[0] == 0 {
templateData := struct {
Name string
}{}
if err := yaml.Unmarshal(contents, &templateData); err == nil && templateData.Name != "" {
return templateData.Name
}
}
return path.Base(filePath)
}

// ExtractContents returns the template contents without the YAML front-matter
func ExtractContents(filePath string) []byte {
contents, err := ioutil.ReadFile(filePath)
if err != nil {
return []byte{}
}
if frontmatterBoundaries := detectFrontmatter(contents); frontmatterBoundaries[0] == 0 {
return contents[frontmatterBoundaries[1]:]
}
return contents
}

var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`)

func detectFrontmatter(c []byte) []int {
if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 {
return []int{matches[0][0], matches[1][1]}
}
return []int{-1, -1}
}
Loading

0 comments on commit d5ba3de

Please sign in to comment.