Skip to content

Commit a45f362

Browse files
committed
Add initial support for issue form templates
1 parent d9cacea commit a45f362

File tree

6 files changed

+314
-53
lines changed

6 files changed

+314
-53
lines changed

modules/context/repo.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package context
88
import (
99
"context"
1010
"fmt"
11+
"gopkg.in/yaml.v2"
1112
"html"
1213
"io"
1314
"net/http"
@@ -1032,6 +1033,25 @@ func UnitTypes() func(ctx *Context) {
10321033
}
10331034
}
10341035

1036+
func ExtractTemplateFromYaml(templateContent []byte, meta *api.IssueTemplate) (tmpl *api.IssueFormTemplate, err error) {
1037+
err = yaml.Unmarshal(templateContent, &tmpl)
1038+
if err != nil {
1039+
return nil, err
1040+
}
1041+
1042+
// Copy metadata
1043+
if meta != nil {
1044+
meta.Name = tmpl.Name
1045+
meta.Title = tmpl.Title
1046+
meta.About = tmpl.About
1047+
meta.Labels = tmpl.Labels
1048+
// TODO: meta.Assignees = tmpl.Assignees
1049+
meta.Ref = tmpl.Ref
1050+
}
1051+
1052+
return
1053+
}
1054+
10351055
// IssueTemplatesFromDefaultBranch checks for issue templates in the repo's default branch
10361056
func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate {
10371057
var issueTemplates []api.IssueTemplate
@@ -1091,6 +1111,39 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate {
10911111
if it.Valid() {
10921112
issueTemplates = append(issueTemplates, it)
10931113
}
1114+
} else if strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml") {
1115+
if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize {
1116+
log.Debug("Issue form template is too large: %s", entry.Name())
1117+
continue
1118+
}
1119+
r, err := entry.Blob().DataAsync()
1120+
if err != nil {
1121+
log.Debug("DataAsync: %v", err)
1122+
continue
1123+
}
1124+
closed := false
1125+
defer func() {
1126+
if !closed {
1127+
_ = r.Close()
1128+
}
1129+
}()
1130+
templateContent, err := io.ReadAll(r)
1131+
if err != nil {
1132+
log.Debug("ReadAll: %v", err)
1133+
continue
1134+
}
1135+
_ = r.Close()
1136+
1137+
var it api.IssueTemplate
1138+
it.FileName = path.Base(entry.Name())
1139+
_, err = ExtractTemplateFromYaml(templateContent, &it)
1140+
if err != nil {
1141+
log.Debug("ExtractTemplateFromYaml: %v", err)
1142+
continue
1143+
}
1144+
if it.Valid() {
1145+
issueTemplates = append(issueTemplates, it)
1146+
}
10941147
}
10951148
}
10961149
if len(issueTemplates) > 0 {

modules/structs/issue_form.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2016 The Gogs Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package structs
6+
7+
import "strings"
8+
9+
type FormField struct {
10+
Type string `yaml:"type"`
11+
Id string `yaml:"id"`
12+
Attributes map[string]interface{} `yaml:"attributes"`
13+
Validations map[string]interface{} `yaml:"validations"`
14+
}
15+
16+
// IssueFormTemplate represents an issue form template for a repository
17+
// swagger:model
18+
type IssueFormTemplate struct {
19+
Name string `yaml:"name"`
20+
Title string `yaml:"title"`
21+
About string `yaml:"description"`
22+
Labels []string `yaml:"labels"`
23+
Assignees []string `yaml:"assignees"`
24+
Ref string `yaml:"ref"`
25+
Fields []FormField `yaml:"body"`
26+
FileName string `yaml:"-"`
27+
}
28+
29+
// Valid checks whether an IssueFormTemplate is considered valid, e.g. at least name and about
30+
func (it IssueFormTemplate) Valid() bool {
31+
if strings.TrimSpace(it.Name) == "" || strings.TrimSpace(it.About) == "" {
32+
return false
33+
}
34+
35+
for _, field := range it.Fields {
36+
if strings.TrimSpace(field.Id) == "" {
37+
// TODO: add IDs should be optional, maybe generate slug from label? or use numberic id
38+
return false
39+
}
40+
}
41+
42+
return true
43+
}

options/locale/locale_en-US.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ forks = Forks
7575
activities = Activities
7676
pull_requests = Pull Requests
7777
issues = Issues
78+
issue = Issue
7879
milestones = Milestones
7980

8081
ok = OK

routers/web/repo/issue.go

Lines changed: 144 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const (
6464
tplReactions base.TplName = "repo/issue/view_content/reactions"
6565

6666
issueTemplateKey = "IssueTemplate"
67+
issueFormTemplateKey = "IssueFormTemplate"
6768
issueTemplateTitleKey = "IssueTemplateTitle"
6869
)
6970

@@ -722,16 +723,16 @@ func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull
722723
return labels
723724
}
724725

725-
func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (string, bool) {
726-
if ctx.Repo.Commit == nil {
726+
func getFileContentFromDefaultBranch(repo *context.Repository, filename string) (string, bool) {
727+
if repo.Commit == nil {
727728
var err error
728-
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
729+
repo.Commit, err = repo.GitRepo.GetBranchCommit(repo.Repository.DefaultBranch)
729730
if err != nil {
730731
return "", false
731732
}
732733
}
733734

734-
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(filename)
735+
entry, err := repo.Commit.GetTreeEntryByPath(filename)
735736
if err != nil {
736737
return "", false
737738
}
@@ -750,53 +751,89 @@ func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (str
750751
return string(bytes), true
751752
}
752753

753-
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) {
754+
func getTemplate(repo *context.Repository, template string, possibleDirs, possibleFiles []string) (
755+
outMeta *api.IssueTemplate,
756+
outTemplateBody string,
757+
outFormTemplateBody *api.IssueFormTemplate,
758+
err error,
759+
) {
760+
// Add `possibleFiles` and each `{possibleDirs}/{template}` to `templateCandidates`
754761
templateCandidates := make([]string, 0, len(possibleFiles))
755-
if ctx.FormString("template") != "" {
762+
if template != "" {
756763
for _, dirName := range possibleDirs {
757-
templateCandidates = append(templateCandidates, path.Join(dirName, ctx.FormString("template")))
764+
templateCandidates = append(templateCandidates, path.Join(dirName, template))
758765
}
759766
}
760767
templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback
768+
761769
for _, filename := range templateCandidates {
762-
templateContent, found := getFileContentFromDefaultBranch(ctx, filename)
770+
// Read each template
771+
templateContent, found := getFileContentFromDefaultBranch(repo, filename)
763772
if found {
764-
var meta api.IssueTemplate
765-
templateBody, err := markdown.ExtractMetadata(templateContent, &meta)
773+
meta := api.IssueTemplate{FileName: filename}
774+
775+
if strings.HasSuffix(filename, ".md") {
776+
// Parse markdown template
777+
outTemplateBody, err = markdown.ExtractMetadata(templateContent, meta)
778+
} else if strings.HasSuffix(filename, ".yaml") || strings.HasSuffix(filename, ".yml") {
779+
// Parse yaml (form) template
780+
outFormTemplateBody, err = context.ExtractTemplateFromYaml([]byte(templateContent), &meta)
781+
outFormTemplateBody.FileName = path.Base(filename)
782+
} else {
783+
err = errors.New("invalid template type")
784+
}
766785
if err != nil {
767-
log.Debug("could not extract metadata from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err)
768-
ctx.Data[ctxDataKey] = templateContent
769-
return
786+
log.Debug("could not extract metadata from %s [%s]: %v", filename, repo.Repository.FullName(), err)
787+
outTemplateBody = templateContent
788+
err = nil
770789
}
771-
ctx.Data[issueTemplateTitleKey] = meta.Title
772-
ctx.Data[ctxDataKey] = templateBody
773-
labelIDs := make([]string, 0, len(meta.Labels))
774-
if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil {
775-
ctx.Data["Labels"] = repoLabels
776-
if ctx.Repo.Owner.IsOrganization() {
777-
if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil {
778-
ctx.Data["OrgLabels"] = orgLabels
779-
repoLabels = append(repoLabels, orgLabels...)
780-
}
781-
}
782790

783-
for _, metaLabel := range meta.Labels {
784-
for _, repoLabel := range repoLabels {
785-
if strings.EqualFold(repoLabel.Name, metaLabel) {
786-
repoLabel.IsChecked = true
787-
labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10))
788-
break
789-
}
790-
}
791+
outMeta = &meta
792+
return
793+
}
794+
}
795+
err = errors.New("no template found")
796+
return
797+
}
798+
799+
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) {
800+
templateMeta, templateBody, formTemplateBody, err := getTemplate(ctx.Repo, ctx.FormString("template"), possibleDirs, possibleFiles)
801+
if err != nil {
802+
return
803+
}
804+
805+
if formTemplateBody != nil {
806+
ctx.Data[issueFormTemplateKey] = formTemplateBody
807+
}
808+
809+
ctx.Data[issueTemplateTitleKey] = templateMeta.Title
810+
ctx.Data[ctxDataKey] = templateBody
811+
812+
labelIDs := make([]string, 0, len(templateMeta.Labels))
813+
if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil {
814+
ctx.Data["Labels"] = repoLabels
815+
if ctx.Repo.Owner.IsOrganization() {
816+
if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil {
817+
ctx.Data["OrgLabels"] = orgLabels
818+
repoLabels = append(repoLabels, orgLabels...)
819+
}
820+
}
821+
822+
for _, metaLabel := range templateMeta.Labels {
823+
for _, repoLabel := range repoLabels {
824+
if strings.EqualFold(repoLabel.Name, metaLabel) {
825+
repoLabel.IsChecked = true
826+
labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10))
827+
break
791828
}
792829
}
793-
ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0
794-
ctx.Data["label_ids"] = strings.Join(labelIDs, ",")
795-
ctx.Data["Reference"] = meta.Ref
796-
ctx.Data["RefEndName"] = git.RefEndName(meta.Ref)
797-
return
798830
}
799831
}
832+
ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0
833+
ctx.Data["label_ids"] = strings.Join(labelIDs, ",")
834+
ctx.Data["Reference"] = templateMeta.Ref
835+
ctx.Data["RefEndName"] = git.RefEndName(templateMeta.Ref)
836+
return
800837
}
801838

802839
// NewIssue render creating issue page
@@ -997,6 +1034,65 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
9971034
return labelIDs, assigneeIDs, milestoneID, form.ProjectID
9981035
}
9991036

1037+
// Renders the given form values to Markdown
1038+
// Returns an empty string if user submitted a non-form issue
1039+
func renderIssueFormValues(ctx *context.Context, form *url.Values) (string, error) {
1040+
// Skip if submitted without a form
1041+
if form.Has("content") || !form.Has("form-type") {
1042+
return "", nil
1043+
}
1044+
1045+
// Fetch template
1046+
_, _, formTemplateBody, err := getTemplate(
1047+
ctx.Repo,
1048+
form.Get("form-type"),
1049+
context.IssueTemplateDirCandidates,
1050+
IssueTemplateCandidates,
1051+
)
1052+
if err != nil {
1053+
return "", err
1054+
}
1055+
if formTemplateBody == nil {
1056+
return "", errors.New("no form template found")
1057+
}
1058+
1059+
// Render values
1060+
result := ""
1061+
for _, field := range formTemplateBody.Fields {
1062+
if field.Id != "" {
1063+
// Get field label
1064+
label := field.Attributes["label"]
1065+
if label == "" {
1066+
label = field.Id
1067+
}
1068+
1069+
// Format the value into Markdown
1070+
switch field.Type {
1071+
case "markdown":
1072+
// Markdown blocks do not appear in output
1073+
case "input", "textarea", "dropdown":
1074+
if renderType, ok := field.Attributes["render"]; ok {
1075+
result += fmt.Sprintf("### %s\n```%s\n%s\n```\n\n", label, renderType, form.Get("form-field-"+field.Id))
1076+
} else {
1077+
result += fmt.Sprintf("### %s\n%s\n\n", label, form.Get("form-field-"+field.Id))
1078+
}
1079+
case "checkboxes":
1080+
result += fmt.Sprintf("### %s\n", label)
1081+
for i, option := range field.Attributes["options"].([]interface{}) {
1082+
checked := " "
1083+
if form.Get(fmt.Sprintf("form-field-%s-%d", field.Id, i)) == "on" {
1084+
checked = "x"
1085+
}
1086+
result += fmt.Sprintf("- [%s] %s\n", checked, option.(map[interface{}]interface{})["label"])
1087+
}
1088+
result += "\n"
1089+
}
1090+
}
1091+
}
1092+
1093+
return result, nil
1094+
}
1095+
10001096
// NewIssuePost response for creating new issue
10011097
func NewIssuePost(ctx *context.Context) {
10021098
form := web.GetForm(ctx).(*forms.CreateIssueForm)
@@ -1031,14 +1127,24 @@ func NewIssuePost(ctx *context.Context) {
10311127
return
10321128
}
10331129

1130+
// If the issue submitted is a form, render it to Markdown
1131+
issueContents, err := renderIssueFormValues(ctx, &ctx.Req.Form)
1132+
if err != nil {
1133+
return
1134+
}
1135+
if issueContents == "" {
1136+
// Not a form
1137+
issueContents = form.Content
1138+
}
1139+
10341140
issue := &issues_model.Issue{
10351141
RepoID: repo.ID,
10361142
Repo: repo,
10371143
Title: form.Title,
10381144
PosterID: ctx.Doer.ID,
10391145
Poster: ctx.Doer,
10401146
MilestoneID: milestoneID,
1041-
Content: form.Content,
1147+
Content: issueContents,
10421148
Ref: form.Ref,
10431149
}
10441150

0 commit comments

Comments
 (0)