From d5ba3de7510cf484a47dd3cf5912f68bd3cb2dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 18 Dec 2019 22:11:25 +0100 Subject: [PATCH 1/6] 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. --- command/issue.go | 14 +- command/pr_create.go | 9 +- command/title_body_survey.go | 41 +++- git/git.go | 8 + pkg/githubtemplate/github_template.go | 99 ++++++++ pkg/githubtemplate/github_template_test.go | 253 +++++++++++++++++++++ 6 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 pkg/githubtemplate/github_template.go create mode 100644 pkg/githubtemplate/github_template_test.go diff --git a/command/issue.go b/command/issue.go index df30b6e52..673ec791d 100644 --- a/command/issue.go +++ b/command/issue.go @@ -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" @@ -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) @@ -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") } diff --git a/command/pr_create.go b/command/pr_create.go index 529925941..7ce86ece7 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -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" @@ -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") } diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 450742d7a..180fdd11f 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -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" ) @@ -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() @@ -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, }, diff --git a/git/git.go b/git/git.go index 4dff19ed2..887135cc8 100644 --- a/git/git.go +++ b/git/git.go @@ -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") diff --git a/pkg/githubtemplate/github_template.go b/pkg/githubtemplate/github_template.go new file mode 100644 index 000000000..d7472a64f --- /dev/null +++ b/pkg/githubtemplate/github_template.go @@ -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} +} diff --git a/pkg/githubtemplate/github_template_test.go b/pkg/githubtemplate/github_template_test.go new file mode 100644 index 000000000..70b501b2b --- /dev/null +++ b/pkg/githubtemplate/github_template_test.go @@ -0,0 +1,253 @@ +package githubtemplate + +import ( + "io/ioutil" + "os" + "path" + "reflect" + "testing" +) + +func TestFind(t *testing.T) { + tmpdir, err := ioutil.TempDir("", "gh-cli") + if err != nil { + t.Fatal(err) + } + + type args struct { + rootDir string + name string + } + tests := []struct { + name string + prepare []string + args args + want []string + }{ + { + name: "Template in root", + prepare: []string{ + "README.md", + "ISSUE_TEMPLATE", + "issue_template.md", + "issue_template.txt", + "pull_request_template.md", + }, + args: args{ + rootDir: tmpdir, + name: "ISSUE_TEMPLATE", + }, + want: []string{ + path.Join(tmpdir, "issue_template.md"), + }, + }, + { + name: "Template in .github takes precedence", + prepare: []string{ + "ISSUE_TEMPLATE.md", + ".github/issue_template.md", + }, + args: args{ + rootDir: tmpdir, + name: "ISSUE_TEMPLATE", + }, + want: []string{ + path.Join(tmpdir, ".github/issue_template.md"), + }, + }, + { + name: "Template in docs", + prepare: []string{ + "README.md", + "docs/issue_template.md", + }, + args: args{ + rootDir: tmpdir, + name: "ISSUE_TEMPLATE", + }, + want: []string{ + path.Join(tmpdir, "docs/issue_template.md"), + }, + }, + { + name: "Multiple templates", + prepare: []string{ + ".github/ISSUE_TEMPLATE/nope.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE/one.md", + ".github/PULL_REQUEST_TEMPLATE/two.md", + ".github/PULL_REQUEST_TEMPLATE/three.md", + "docs/pull_request_template.md", + }, + args: args{ + rootDir: tmpdir, + name: "PuLl_ReQuEsT_TeMpLaTe", + }, + want: []string{ + path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE/one.md"), + path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE/three.md"), + path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE/two.md"), + }, + }, + { + name: "Empty multiple templates directory", + prepare: []string{ + ".github/issue_template.md", + ".github/issue_template/.keep", + }, + args: args{ + rootDir: tmpdir, + name: "ISSUE_TEMPLATE", + }, + want: []string{ + path.Join(tmpdir, ".github/issue_template.md"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, p := range tt.prepare { + fp := path.Join(tmpdir, p) + os.MkdirAll(path.Dir(fp), 0700) + file, err := os.Create(fp) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + if got := Find(tt.args.rootDir, tt.args.name); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Find() = %v, want %v", got, tt.want) + } + }) + os.RemoveAll(tmpdir) + } +} + +func TestExtractName(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "gh-cli") + if err != nil { + t.Fatal(err) + } + tmpfile.Close() + defer os.Remove(tmpfile.Name()) + + type args struct { + filePath string + } + tests := []struct { + name string + prepare string + args args + want string + }{ + { + name: "Complete front-matter", + prepare: `--- +name: Bug Report +about: This is how you report bugs +--- + +Template contents +--- +More of template +`, + args: args{ + filePath: tmpfile.Name(), + }, + want: "Bug Report", + }, + { + name: "Incomplete front-matter", + prepare: `--- +about: This is how you report bugs +--- +`, + args: args{ + filePath: tmpfile.Name(), + }, + want: path.Base(tmpfile.Name()), + }, + { + name: "No front-matter", + prepare: `name: This is not yaml!`, + args: args{ + filePath: tmpfile.Name(), + }, + want: path.Base(tmpfile.Name()), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600) + if got := ExtractName(tt.args.filePath); got != tt.want { + t.Errorf("ExtractName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractContents(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "gh-cli") + if err != nil { + t.Fatal(err) + } + tmpfile.Close() + defer os.Remove(tmpfile.Name()) + + type args struct { + filePath string + } + tests := []struct { + name string + prepare string + args args + want string + }{ + { + name: "Has front-matter", + prepare: `--- +name: Bug Report +--- + + +Template contents +--- +More of template +`, + args: args{ + filePath: tmpfile.Name(), + }, + want: `Template contents +--- +More of template +`, + }, + { + name: "No front-matter", + prepare: `Template contents +--- +More of template +--- +Even more +`, + args: args{ + filePath: tmpfile.Name(), + }, + want: `Template contents +--- +More of template +--- +Even more +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600) + if got := ExtractContents(tt.args.filePath); string(got) != tt.want { + t.Errorf("ExtractContents() = %v, want %v", string(got), tt.want) + } + }) + } +} From d553f45bd1524594285a0bfd48ad178e837444f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 20 Dec 2019 15:05:37 +0100 Subject: [PATCH 2/6] Replace `goto` with `break