summaryrefslogtreecommitdiffstats
path: root/modules
diff options
context:
space:
mode:
authorJason Song <i@wolfogre.com>2022-09-02 15:58:49 +0800
committerGitHub <noreply@github.com>2022-09-02 15:58:49 +0800
commit84447df4d366324ab81894b028b00fd66be85caf (patch)
tree5291442a85faccb6bc17b54ca71a53c16530dfe3 /modules
parentb7a4b45ff83dc19febcfb85279215ea6bd224033 (diff)
downloadgitea-84447df4d366324ab81894b028b00fd66be85caf.tar.gz
gitea-84447df4d366324ab81894b028b00fd66be85caf.zip
Support Issue forms and PR forms (#20987)
* feat: extend issue template for yaml * feat: support yaml template * feat: render form to markdown * feat: support yaml template for pr * chore: rename to Fields * feat: template unmarshal * feat: split template * feat: render to markdown * feat: use full name as template file name * chore: remove useless file * feat: use dropdown of fomantic ui * feat: update input style * docs: more comments * fix: render text without render * chore: fix lint error * fix: support use description as about in markdown * fix: add field class in form * chore: generate swagger * feat: validate template * feat: support is_nummber and regex * test: fix broken unit tests * fix: ignore empty body of md template * fix: make multiple easymde editors work in one page * feat: better UI * fix: js error in pr form * chore: generate swagger * feat: support regex validation * chore: generate swagger * fix: refresh each markdown editor * chore: give up required validation * fix: correct issue template candidates * fix: correct checkboxes style * chore: ignore .hugo_build.lock in docs * docs: separate out a new doc for merge templates * docs: introduce syntax of yaml template * feat: show a alert for invalid templates * test: add case for a valid template * fix: correct attributes of required checkbox * fix: add class not-under-easymde for dropzone * fix: use more back-quotes * chore: remove translation in zh-CN * fix EasyMDE statusbar margin * fix: remove repeated blocks * fix: reuse regex for quotes Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Diffstat (limited to 'modules')
-rw-r--r--modules/context/repo.go71
-rw-r--r--modules/issue/template/template.go392
-rw-r--r--modules/issue/template/template_test.go645
-rw-r--r--modules/issue/template/unmarshal.go125
-rw-r--r--modules/markup/markdown/meta_test.go15
-rw-r--r--modules/structs/issue.go60
6 files changed, 1250 insertions, 58 deletions
diff --git a/modules/context/repo.go b/modules/context/repo.go
index 5404acc05a..6a336c45f7 100644
--- a/modules/context/repo.go
+++ b/modules/context/repo.go
@@ -9,7 +9,6 @@ import (
"context"
"fmt"
"html"
- "io"
"net/http"
"net/url"
"path"
@@ -26,8 +25,8 @@ import (
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git"
code_indexer "code.gitea.io/gitea/modules/indexer/code"
+ "code.gitea.io/gitea/modules/issue/template"
"code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/markup/markdown"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
@@ -1034,70 +1033,52 @@ func UnitTypes() func(ctx *Context) {
}
}
-// IssueTemplatesFromDefaultBranch checks for issue templates in the repo's default branch
-func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate {
- var issueTemplates []api.IssueTemplate
+// IssueTemplatesFromDefaultBranch checks for valid issue templates in the repo's default branch,
+func (ctx *Context) IssueTemplatesFromDefaultBranch() []*api.IssueTemplate {
+ ret, _ := ctx.IssueTemplatesErrorsFromDefaultBranch()
+ return ret
+}
+
+// IssueTemplatesErrorsFromDefaultBranch checks for issue templates in the repo's default branch,
+// returns valid templates and the errors of invalid template files.
+func (ctx *Context) IssueTemplatesErrorsFromDefaultBranch() ([]*api.IssueTemplate, map[string]error) {
+ var issueTemplates []*api.IssueTemplate
if ctx.Repo.Repository.IsEmpty {
- return issueTemplates
+ return issueTemplates, nil
}
if ctx.Repo.Commit == nil {
var err error
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil {
- return issueTemplates
+ return issueTemplates, nil
}
}
+ invalidFiles := map[string]error{}
for _, dirName := range IssueTemplateDirCandidates {
tree, err := ctx.Repo.Commit.SubTree(dirName)
if err != nil {
+ log.Debug("get sub tree of %s: %v", dirName, err)
continue
}
entries, err := tree.ListEntries()
if err != nil {
- return issueTemplates
+ log.Debug("list entries in %s: %v", dirName, err)
+ return issueTemplates, nil
}
for _, entry := range entries {
- if strings.HasSuffix(entry.Name(), ".md") {
- if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize {
- log.Debug("Issue template is too large: %s", entry.Name())
- continue
- }
- r, err := entry.Blob().DataAsync()
- if err != nil {
- log.Debug("DataAsync: %v", err)
- continue
- }
- closed := false
- defer func() {
- if !closed {
- _ = r.Close()
- }
- }()
- data, err := io.ReadAll(r)
- if err != nil {
- log.Debug("ReadAll: %v", err)
- continue
- }
- _ = r.Close()
- var it api.IssueTemplate
- content, err := markdown.ExtractMetadata(string(data), &it)
- if err != nil {
- log.Debug("ExtractMetadata: %v", err)
- continue
- }
- it.Content = content
- it.FileName = entry.Name()
- if it.Valid() {
- issueTemplates = append(issueTemplates, it)
- }
+ if !template.CouldBe(entry.Name()) {
+ continue
+ }
+ fullName := path.Join(dirName, entry.Name())
+ if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil {
+ invalidFiles[fullName] = err
+ } else {
+ issueTemplates = append(issueTemplates, it)
}
- }
- if len(issueTemplates) > 0 {
- return issueTemplates
}
}
- return issueTemplates
+ return issueTemplates, invalidFiles
}
diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go
new file mode 100644
index 0000000000..a4c0fb5aa6
--- /dev/null
+++ b/modules/issue/template/template.go
@@ -0,0 +1,392 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package template
+
+import (
+ "fmt"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+
+ api "code.gitea.io/gitea/modules/structs"
+
+ "gitea.com/go-chi/binding"
+)
+
+// Validate checks whether an IssueTemplate is considered valid, and returns the first error
+func Validate(template *api.IssueTemplate) error {
+ if err := validateMetadata(template); err != nil {
+ return err
+ }
+ if template.Type() == api.IssueTemplateTypeYaml {
+ if err := validateYaml(template); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateMetadata(template *api.IssueTemplate) error {
+ if strings.TrimSpace(template.Name) == "" {
+ return fmt.Errorf("'name' is required")
+ }
+ if strings.TrimSpace(template.About) == "" {
+ return fmt.Errorf("'about' is required")
+ }
+ return nil
+}
+
+func validateYaml(template *api.IssueTemplate) error {
+ if len(template.Fields) == 0 {
+ return fmt.Errorf("'body' is required")
+ }
+ ids := map[string]struct{}{}
+ for idx, field := range template.Fields {
+ if err := validateID(field, idx, ids); err != nil {
+ return err
+ }
+ if err := validateLabel(field, idx); err != nil {
+ return err
+ }
+
+ position := newErrorPosition(idx, field.Type)
+ switch field.Type {
+ case api.IssueFormFieldTypeMarkdown:
+ if err := validateStringItem(position, field.Attributes, true, "value"); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeTextarea:
+ if err := validateStringItem(position, field.Attributes, false,
+ "description",
+ "placeholder",
+ "value",
+ "render",
+ ); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeInput:
+ if err := validateStringItem(position, field.Attributes, false,
+ "description",
+ "placeholder",
+ "value",
+ ); err != nil {
+ return err
+ }
+ if err := validateBoolItem(position, field.Validations, "is_number"); err != nil {
+ return err
+ }
+ if err := validateStringItem(position, field.Validations, false, "regex"); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeDropdown:
+ if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
+ return err
+ }
+ if err := validateBoolItem(position, field.Attributes, "multiple"); err != nil {
+ return err
+ }
+ if err := validateOptions(field, idx); err != nil {
+ return err
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
+ return err
+ }
+ if err := validateOptions(field, idx); err != nil {
+ return err
+ }
+ default:
+ return position.Errorf("unknown type")
+ }
+
+ if err := validateRequired(field, idx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateLabel(field *api.IssueFormField, idx int) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown {
+ // The label is not required for a markdown field
+ return nil
+ }
+ return validateStringItem(newErrorPosition(idx, field.Type), field.Attributes, true, "label")
+}
+
+func validateRequired(field *api.IssueFormField, idx int) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown || field.Type == api.IssueFormFieldTypeCheckboxes {
+ // The label is not required for a markdown or checkboxes field
+ return nil
+ }
+ return validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required")
+}
+
+func validateID(field *api.IssueFormField, idx int, ids map[string]struct{}) error {
+ if field.Type == api.IssueFormFieldTypeMarkdown {
+ // The ID is not required for a markdown field
+ return nil
+ }
+
+ position := newErrorPosition(idx, field.Type)
+ if field.ID == "" {
+ // If the ID is empty in yaml, template.Unmarshal will auto autofill it, so it cannot be empty
+ return position.Errorf("'id' is required")
+ }
+ if binding.AlphaDashPattern.MatchString(field.ID) {
+ return position.Errorf("'id' should contain only alphanumeric, '-' and '_'")
+ }
+ if _, ok := ids[field.ID]; ok {
+ return position.Errorf("'id' should be unique")
+ }
+ ids[field.ID] = struct{}{}
+ return nil
+}
+
+func validateOptions(field *api.IssueFormField, idx int) error {
+ if field.Type != api.IssueFormFieldTypeDropdown && field.Type != api.IssueFormFieldTypeCheckboxes {
+ return nil
+ }
+ position := newErrorPosition(idx, field.Type)
+
+ options, ok := field.Attributes["options"].([]interface{})
+ if !ok || len(options) == 0 {
+ return position.Errorf("'options' is required and should be a array")
+ }
+
+ for optIdx, option := range options {
+ position := newErrorPosition(idx, field.Type, optIdx)
+ switch field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ if _, ok := option.(string); !ok {
+ return position.Errorf("should be a string")
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ opt, ok := option.(map[interface{}]interface{})
+ if !ok {
+ return position.Errorf("should be a dictionary")
+ }
+ if label, ok := opt["label"].(string); !ok || label == "" {
+ return position.Errorf("'label' is required and should be a string")
+ }
+
+ if required, ok := opt["required"]; ok {
+ if _, ok := required.(bool); !ok {
+ return position.Errorf("'required' should be a bool")
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func validateStringItem(position errorPosition, m map[string]interface{}, required bool, names ...string) error {
+ for _, name := range names {
+ v, ok := m[name]
+ if !ok {
+ if required {
+ return position.Errorf("'%s' is required", name)
+ }
+ return nil
+ }
+ attr, ok := v.(string)
+ if !ok {
+ return position.Errorf("'%s' should be a string", name)
+ }
+ if strings.TrimSpace(attr) == "" && required {
+ return position.Errorf("'%s' is required", name)
+ }
+ }
+ return nil
+}
+
+func validateBoolItem(position errorPosition, m map[string]interface{}, names ...string) error {
+ for _, name := range names {
+ v, ok := m[name]
+ if !ok {
+ return nil
+ }
+ if _, ok := v.(bool); !ok {
+ return position.Errorf("'%s' should be a bool", name)
+ }
+ }
+ return nil
+}
+
+type errorPosition string
+
+func (p errorPosition) Errorf(format string, a ...interface{}) error {
+ return fmt.Errorf(string(p)+": "+format, a...)
+}
+
+func newErrorPosition(fieldIdx int, fieldType api.IssueFormFieldType, optionIndex ...int) errorPosition {
+ ret := fmt.Sprintf("body[%d](%s)", fieldIdx, fieldType)
+ if len(optionIndex) > 0 {
+ ret += fmt.Sprintf(", option[%d]", optionIndex[0])
+ }
+ return errorPosition(ret)
+}
+
+// RenderToMarkdown renders template to markdown with specified values
+func RenderToMarkdown(template *api.IssueTemplate, values url.Values) string {
+ builder := &strings.Builder{}
+
+ for _, field := range template.Fields {
+ f := &valuedField{
+ IssueFormField: field,
+ Values: values,
+ }
+ if f.ID == "" {
+ continue
+ }
+ f.WriteTo(builder)
+ }
+
+ return builder.String()
+}
+
+type valuedField struct {
+ *api.IssueFormField
+ url.Values
+}
+
+func (f *valuedField) WriteTo(builder *strings.Builder) {
+ if f.Type == api.IssueFormFieldTypeMarkdown {
+ // markdown blocks do not appear in output
+ return
+ }
+
+ // write label
+ _, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label())
+
+ blankPlaceholder := "_No response_\n"
+
+ // write body
+ switch f.Type {
+ case api.IssueFormFieldTypeCheckboxes:
+ for _, option := range f.Options() {
+ checked := " "
+ if option.IsChecked() {
+ checked = "x"
+ }
+ _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label())
+ }
+ case api.IssueFormFieldTypeDropdown:
+ var checkeds []string
+ for _, option := range f.Options() {
+ if option.IsChecked() {
+ checkeds = append(checkeds, option.Label())
+ }
+ }
+ if len(checkeds) > 0 {
+ _, _ = fmt.Fprintf(builder, "%s\n", strings.Join(checkeds, ", "))
+ } else {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ }
+ case api.IssueFormFieldTypeInput:
+ if value := f.Value(); value == "" {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ } else {
+ _, _ = fmt.Fprintf(builder, "%s\n", value)
+ }
+ case api.IssueFormFieldTypeTextarea:
+ if value := f.Value(); value == "" {
+ _, _ = fmt.Fprint(builder, blankPlaceholder)
+ } else if render := f.Render(); render != "" {
+ quotes := minQuotes(value)
+ _, _ = fmt.Fprintf(builder, "%s%s\n%s\n%s\n", quotes, f.Render(), value, quotes)
+ } else {
+ _, _ = fmt.Fprintf(builder, "%s\n", value)
+ }
+ }
+ _, _ = fmt.Fprintln(builder)
+}
+
+func (f *valuedField) Label() string {
+ if label, ok := f.Attributes["label"].(string); ok {
+ return label
+ }
+ return ""
+}
+
+func (f *valuedField) Render() string {
+ if render, ok := f.Attributes["render"].(string); ok {
+ return render
+ }
+ return ""
+}
+
+func (f *valuedField) Value() string {
+ return strings.TrimSpace(f.Get(fmt.Sprintf("form-field-" + f.ID)))
+}
+
+func (f *valuedField) Options() []*valuedOption {
+ if options, ok := f.Attributes["options"].([]interface{}); ok {
+ ret := make([]*valuedOption, 0, len(options))
+ for i, option := range options {
+ ret = append(ret, &valuedOption{
+ index: i,
+ data: option,
+ field: f,
+ })
+ }
+ return ret
+ }
+ return nil
+}
+
+type valuedOption struct {
+ index int
+ data interface{}
+ field *valuedField
+}
+
+func (o *valuedOption) Label() string {
+ switch o.field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ if label, ok := o.data.(string); ok {
+ return label
+ }
+ case api.IssueFormFieldTypeCheckboxes:
+ if vs, ok := o.data.(map[interface{}]interface{}); ok {
+ if v, ok := vs["label"].(string); ok {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func (o *valuedOption) IsChecked() bool {
+ switch o.field.Type {
+ case api.IssueFormFieldTypeDropdown:
+ checks := strings.Split(o.field.Get(fmt.Sprintf("form-field-%s", o.field.ID)), ",")
+ idx := strconv.Itoa(o.index)
+ for _, v := range checks {
+ if v == idx {
+ return true
+ }
+ }
+ return false
+ case api.IssueFormFieldTypeCheckboxes:
+ return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on"
+ }
+ return false
+}
+
+var minQuotesRegex = regexp.MustCompilePOSIX("^`{3,}")
+
+// minQuotes return 3 or more back-quotes.
+// If n back-quotes exists, use n+1 back-quotes to quote.
+func minQuotes(value string) string {
+ ret := "```"
+ for _, v := range minQuotesRegex.FindAllString(value, -1) {
+ if len(v) >= len(ret) {
+ ret = v + "`"
+ }
+ }
+ return ret
+}
diff --git a/modules/issue/template/template_test.go b/modules/issue/template/template_test.go
new file mode 100644
index 0000000000..883e1e0780
--- /dev/null
+++ b/modules/issue/template/template_test.go
@@ -0,0 +1,645 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package template
+
+import (
+ "net/url"
+ "reflect"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+)
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ wantErr string
+ }{
+ {
+ name: "miss name",
+ content: ``,
+ wantErr: "'name' is required",
+ },
+ {
+ name: "miss about",
+ content: `
+name: "test"
+`,
+ wantErr: "'about' is required",
+ },
+ {
+ name: "miss body",
+ content: `
+name: "test"
+about: "this is about"
+`,
+ wantErr: "'body' is required",
+ },
+ {
+ name: "markdown miss value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+`,
+ wantErr: "body[0](markdown): 'value' is required",
+ },
+ {
+ name: "markdown invalid value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+ attributes:
+ value: true
+`,
+ wantErr: "body[0](markdown): 'value' should be a string",
+ },
+ {
+ name: "markdown empty value",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "markdown"
+ attributes:
+ value: ""
+`,
+ wantErr: "body[0](markdown): 'value' is required",
+ },
+ {
+ name: "textarea invalid id",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "?"
+`,
+ wantErr: "body[0](textarea): 'id' should contain only alphanumeric, '-' and '_'",
+ },
+ {
+ name: "textarea miss label",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+`,
+ wantErr: "body[0](textarea): 'label' is required",
+ },
+ {
+ name: "textarea conflict id",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "b"
+`,
+ wantErr: "body[1](textarea): 'id' should be unique",
+ },
+ {
+ name: "textarea invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](textarea): 'description' should be a string",
+ },
+ {
+ name: "textarea invalid required",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "textarea"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ required: "on"
+`,
+ wantErr: "body[0](textarea): 'required' should be a bool",
+ },
+ {
+ name: "input invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](input): 'description' should be a string",
+ },
+ {
+ name: "input invalid is_number",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ is_number: "yes"
+`,
+ wantErr: "body[0](input): 'is_number' should be a bool",
+ },
+ {
+ name: "input invalid regex",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "input"
+ id: "1"
+ attributes:
+ label: "a"
+ validations:
+ regex: true
+`,
+ wantErr: "body[0](input): 'regex' should be a string",
+ },
+ {
+ name: "dropdown invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](dropdown): 'description' should be a string",
+ },
+ {
+ name: "dropdown invalid multiple",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ multiple: "on"
+`,
+ wantErr: "body[0](dropdown): 'multiple' should be a bool",
+ },
+ {
+ name: "checkboxes invalid description",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ description: true
+`,
+ wantErr: "body[0](checkboxes): 'description' should be a string",
+ },
+ {
+ name: "invalid type",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "video"
+ id: "1"
+ attributes:
+ label: "a"
+`,
+ wantErr: "body[0](video): unknown type",
+ },
+ {
+ name: "dropdown miss options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+`,
+ wantErr: "body[0](dropdown): 'options' is required and should be a array",
+ },
+ {
+ name: "dropdown invalid options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "dropdown"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - "a"
+ - true
+`,
+ wantErr: "body[0](dropdown), option[1]: should be a string",
+ },
+ {
+ name: "checkboxes invalid options",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - "a"
+ - true
+`,
+ wantErr: "body[0](checkboxes), option[0]: should be a dictionary",
+ },
+ {
+ name: "checkboxes option miss label",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - required: true
+`,
+ wantErr: "body[0](checkboxes), option[0]: 'label' is required and should be a string",
+ },
+ {
+ name: "checkboxes option invalid required",
+ content: `
+name: "test"
+about: "this is about"
+body:
+ - type: "checkboxes"
+ id: "1"
+ attributes:
+ label: "a"
+ options:
+ - label: "a"
+ required: "on"
+`,
+ wantErr: "body[0](checkboxes), option[0]: 'required' should be a bool",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpl, err := unmarshal("test.yaml", []byte(tt.content))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Validate(tmpl); (err == nil) != (tt.wantErr == "") || err != nil && err.Error() != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %q", err, tt.wantErr)
+ }
+ })
+ }
+
+ t.Run("valid", func(t *testing.T) {
+ content := `
+name: Name
+title: Title
+about: About
+labels: ["label1", "label2"]
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown
+ - type: textarea
+ id: id2
+ attributes:
+ label: Label of textarea
+ description: Description of textarea
+ placeholder: Placeholder of textarea
+ value: Value of textarea
+ render: bash
+ validations:
+ required: true
+ - type: input
+ id: id3
+ attributes:
+ label: Label of input
+ description: Description of input
+ placeholder: Placeholder of input
+ value: Value of input
+ validations:
+ required: true
+ is_number: true
+ regex: "[a-zA-Z0-9]+"
+ - type: dropdown
+ id: id4
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ validations:
+ required: true
+ - type: checkboxes
+ id: id5
+ attributes:
+ label: Label of checkboxes
+ description: Description of checkboxes
+ options:
+ - label: Option 1 of checkboxes
+ required: true
+ - label: Option 2 of checkboxes
+ required: false
+ - label: Option 3 of checkboxes
+ required: true
+`
+ want := &api.IssueTemplate{
+ Name: "Name",
+ Title: "Title",
+ About: "About",
+ Labels: []string{"label1", "label2"},
+ Ref: "Ref",
+ Fields: []*api.IssueFormField{
+ {
+ Type: "markdown",
+ ID: "id1",
+ Attributes: map[string]interface{}{
+ "value": "Value of the markdown",
+ },
+ },
+ {
+ Type: "textarea",
+ ID: "id2",
+ Attributes: map[string]interface{}{
+ "label": "Label of textarea",
+ "description": "Description of textarea",
+ "placeholder": "Placeholder of textarea",
+ "value": "Value of textarea",
+ "render": "bash",
+ },
+ Validations: map[string]interface{}{
+ "required": true,
+ },
+ },
+ {
+ Type: "input",
+ ID: "id3",
+ Attributes: map[string]interface{}{
+ "label": "Label of input",
+ "description": "Description of input",
+ "placeholder": "Placeholder of input",
+ "value": "Value of input",
+ },
+ Validations: map[string]interface{}{
+ "required": true,
+ "is_number": true,
+ "regex": "[a-zA-Z0-9]+",
+ },
+ },
+ {
+ Type: "dropdown",
+ ID: "id4",
+ Attributes: map[string]interface{}{
+ "label": "Label of dropdown",
+ "description": "Description of dropdown",
+ "multiple": true,
+ "options": []interface{}{
+ "Option 1 of dropdown",
+ "Option 2 of dropdown",
+ "Option 3 of dropdown",
+ },
+ },
+ Validations: map[string]interface{}{
+ "required": true,
+ },
+ },
+ {
+ Type: "checkboxes",
+ ID: "id5",
+ Attributes: map[string]interface{}{
+ "label": "Label of checkboxes",
+ "description": "Description of checkboxes",
+ "options": []interface{}{
+ map[interface{}]interface{}{"label": "Option 1 of checkboxes", "required": true},
+ map[interface{}]interface{}{"label": "Option 2 of checkboxes", "required": false},
+ map[interface{}]interface{}{"label": "Option 3 of checkboxes", "required": true},
+ },
+ },
+ },
+ },
+ FileName: "test.yaml",
+ }
+ got, err := unmarshal("test.yaml", []byte(content))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Validate(got); err != nil {
+ t.Errorf("Validate() error = %v", err)
+ }
+ if !reflect.DeepEqual(want, got) {
+ jsonWant, _ := json.Marshal(want)
+ jsonGot, _ := json.Marshal(got)
+ t.Errorf("want:\n%s\ngot:\n%s", jsonWant, jsonGot)
+ }
+ })
+}
+
+func TestRenderToMarkdown(t *testing.T) {
+ type args struct {
+ template string
+ values url.Values
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ name: "normal",
+ args: args{
+ template: `
+name: Name
+title: Title
+about: About
+labels: ["label1", "label2"]
+ref: Ref
+body:
+ - type: markdown
+ id: id1
+ attributes:
+ value: Value of the markdown
+ - type: textarea
+ id: id2
+ attributes:
+ label: Label of textarea
+ description: Description of textarea
+ placeholder: Placeholder of textarea
+ value: Value of textarea
+ render: bash
+ validations:
+ required: true
+ - type: input
+ id: id3
+ attributes:
+ label: Label of input
+ description: Description of input
+ placeholder: Placeholder of input
+ value: Value of input
+ validations:
+ required: true
+ is_number: true
+ regex: "[a-zA-Z0-9]+"
+ - type: dropdown
+ id: id4
+ attributes:
+ label: Label of dropdown
+ description: Description of dropdown
+ multiple: true
+ options:
+ - Option 1 of dropdown
+ - Option 2 of dropdown
+ - Option 3 of dropdown
+ validations:
+ required: true
+ - type: checkboxes
+ id: id5
+ attributes:
+ label: Label of checkboxes
+ description: Description of checkboxes
+ options:
+ - label: Option 1 of checkboxes
+ required: true
+ - label: Option 2 of checkboxes
+ required: false
+ - label: Option 3 of checkboxes
+ required: true
+`,
+ values: map[string][]string{
+ "form-field-id2": {"Value of id2"},
+ "form-field-id3": {"Value of id3"},
+ "form-field-id4": {"0,1"},
+ "form-field-id5-0": {"on"},
+ "form-field-id5-2": {"on"},
+ },
+ },
+ want: `### Label of textarea
+
+` + "```bash\nValue of id2\n```" + `
+
+### Label of input
+
+Value of id3
+
+### Label of dropdown
+
+Option 1 of dropdown, Option 2 of dropdown
+
+### Label of checkboxes
+
+- [x] Option 1 of checkboxes
+- [ ] Option 2 of checkboxes
+- [x] Option 3 of checkboxes
+
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ template, err := Unmarshal("test.yaml", []byte(tt.args.template))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := RenderToMarkdown(template, tt.args.values); got != tt.want {
+ t.Errorf("RenderToMarkdown() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_minQuotes(t *testing.T) {
+ type args struct {
+ value string
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {
+ name: "without quote",
+ args: args{
+ value: "Hello\nWorld",
+ },
+ want: "```",
+ },
+ {
+ name: "with 1 quote",
+ args: args{
+ value: "Hello\nWorld\n`text`\n",
+ },
+ want: "```",
+ },
+ {
+ name: "with 3 quotes",
+ args: args{
+ value: "Hello\nWorld\n`text`\n```go\ntext\n```\n",
+ },
+ want: "````",
+ },
+ {
+ name: "with more quotes",
+ args: args{
+ value: "Hello\nWorld\n`text`\n```go\ntext\n```\n``````````bash\ntext\n``````````\n",
+ },
+ want: "```````````",
+ },
+ {
+ name: "not leading quotes",
+ args: args{
+ value: "Hello\nWorld`text````go\ntext`````````````bash\ntext``````````\n",
+ },
+ want: "```",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := minQuotes(tt.args.value); got != tt.want {
+ t.Errorf("minQuotes() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go
new file mode 100644
index 0000000000..e695d1e1cc
--- /dev/null
+++ b/modules/issue/template/unmarshal.go
@@ -0,0 +1,125 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package template
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strconv"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "gopkg.in/yaml.v2"
+)
+
+// CouldBe indicates a file with the filename could be a template,
+// it is a low cost check before further processing.
+func CouldBe(filename string) bool {
+ it := &api.IssueTemplate{
+ FileName: filename,
+ }
+ return it.Type() != ""
+}
+
+// Unmarshal parses out a valid template from the content
+func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) {
+ it, err := unmarshal(filename, content)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := Validate(it); err != nil {
+ return nil, err
+ }
+
+ return it, nil
+}
+
+// UnmarshalFromEntry parses out a valid template from the blob in entry
+func UnmarshalFromEntry(entry *git.TreeEntry, dir string) (*api.IssueTemplate, error) {
+ return unmarshalFromEntry(entry, filepath.Join(dir, entry.Name()))
+}
+
+// UnmarshalFromCommit parses out a valid template from the commit
+func UnmarshalFromCommit(commit *git.Commit, filename string) (*api.IssueTemplate, error) {
+ entry, err := commit.GetTreeEntryByPath(filename)
+ if err != nil {
+ return nil, fmt.Errorf("get entry for %q: %w", filename, err)
+ }
+ return unmarshalFromEntry(entry, filename)
+}
+
+// UnmarshalFromRepo parses out a valid template from the head commit of the branch
+func UnmarshalFromRepo(repo *git.Repository, branch, filename string) (*api.IssueTemplate, error) {
+ commit, err := repo.GetBranchCommit(branch)
+ if err != nil {
+ return nil, fmt.Errorf("get commit on branch %q: %w", branch, err)
+ }
+
+ return UnmarshalFromCommit(commit, filename)
+}
+
+func unmarshalFromEntry(entry *git.TreeEntry, filename string) (*api.IssueTemplate, error) {
+ if size := entry.Blob().Size(); size > setting.UI.MaxDisplayFileSize {
+ return nil, fmt.Errorf("too large: %v > MaxDisplayFileSize", size)
+ }
+
+ r, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil, fmt.Errorf("data async: %w", err)
+ }
+ defer r.Close()
+
+ content, err := io.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("read all: %w", err)
+ }
+
+ return Unmarshal(filename, content)
+}
+
+func unmarshal(filename string, content []byte) (*api.IssueTemplate, error) {
+ it := &api.IssueTemplate{
+ FileName: filename,
+ }
+
+ // Compatible with treating description as about
+ compatibleTemplate := &struct {
+ About string `yaml:"description"`
+ }{}
+
+ if typ := it.Type(); typ == api.IssueTemplateTypeMarkdown {
+ templateBody, err := markdown.ExtractMetadata(string(content), it)
+ if err != nil {
+ return nil, err
+ }
+ it.Content = templateBody
+ if it.About == "" {
+ if _, err := markdown.ExtractMetadata(string(content), compatibleTemplate); err == nil && compatibleTemplate.About != "" {
+ it.About = compatibleTemplate.About
+ }
+ }
+ } else if typ == api.IssueTemplateTypeYaml {
+ if err := yaml.Unmarshal(content, it); err != nil {
+ return nil, fmt.Errorf("yaml unmarshal: %w", err)
+ }
+ if it.About == "" {
+ if err := yaml.Unmarshal(content, compatibleTemplate); err == nil && compatibleTemplate.About != "" {
+ it.About = compatibleTemplate.About
+ }
+ }
+ for i, v := range it.Fields {
+ if v.ID == "" {
+ v.ID = strconv.Itoa(i)
+ }
+ }
+ }
+
+ return it, nil
+}
diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go
index f525777a54..939646f8fd 100644
--- a/modules/markup/markdown/meta_test.go
+++ b/modules/markup/markdown/meta_test.go
@@ -6,6 +6,7 @@ package markdown
import (
"fmt"
+ "strings"
"testing"
"code.gitea.io/gitea/modules/structs"
@@ -13,6 +14,16 @@ import (
"github.com/stretchr/testify/assert"
)
+func validateMetadata(it structs.IssueTemplate) bool {
+ /*
+ A legacy to keep the unit tests working.
+ Copied from the method "func (it IssueTemplate) Valid() bool", the original method has been removed.
+ Because it becomes quite complicated to validate an issue template which is support yaml form now.
+ The new way to validate an issue template is to call the Validate in modules/issue/template,
+ */
+ return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != ""
+}
+
func TestExtractMetadata(t *testing.T) {
t.Run("ValidFrontAndBody", func(t *testing.T) {
var meta structs.IssueTemplate
@@ -20,7 +31,7 @@ func TestExtractMetadata(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, bodyTest, body)
assert.Equal(t, metaTest, meta)
- assert.True(t, meta.Valid())
+ assert.True(t, validateMetadata(meta))
})
t.Run("NoFirstSeparator", func(t *testing.T) {
@@ -41,7 +52,7 @@ func TestExtractMetadata(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "", body)
assert.Equal(t, metaTest, meta)
- assert.True(t, meta.Valid())
+ assert.True(t, validateMetadata(meta))
})
}
diff --git a/modules/structs/issue.go b/modules/structs/issue.go
index c72487fe4d..27ec81f728 100644
--- a/modules/structs/issue.go
+++ b/modules/structs/issue.go
@@ -5,7 +5,7 @@
package structs
import (
- "strings"
+ "path/filepath"
"time"
)
@@ -120,19 +120,57 @@ type IssueDeadline struct {
Deadline *time.Time `json:"due_date"`
}
+// IssueFormFieldType defines issue form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes"
+type IssueFormFieldType string
+
+const (
+ IssueFormFieldTypeMarkdown IssueFormFieldType = "markdown"
+ IssueFormFieldTypeTextarea IssueFormFieldType = "textarea"
+ IssueFormFieldTypeInput IssueFormFieldType = "input"
+ IssueFormFieldTypeDropdown IssueFormFieldType = "dropdown"
+ IssueFormFieldTypeCheckboxes IssueFormFieldType = "checkboxes"
+)
+
+// IssueFormField represents a form field
+// swagger:model
+type IssueFormField struct {
+ Type IssueFormFieldType `json:"type" yaml:"type"`
+ ID string `json:"id" yaml:"id"`
+ Attributes map[string]interface{} `json:"attributes" yaml:"attributes"`
+ Validations map[string]interface{} `json:"validations" yaml:"validations"`
+}
+
// IssueTemplate represents an issue template for a repository
// swagger:model
type IssueTemplate struct {
- Name string `json:"name" yaml:"name"`
- Title string `json:"title" yaml:"title"`
- About string `json:"about" yaml:"about"`
- Labels []string `json:"labels" yaml:"labels"`
- Ref string `json:"ref" yaml:"ref"`
- Content string `json:"content" yaml:"-"`
- FileName string `json:"file_name" yaml:"-"`
+ Name string `json:"name" yaml:"name"`
+ Title string `json:"title" yaml:"title"`
+ About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
+ Labels []string `json:"labels" yaml:"labels"`
+ Ref string `json:"ref" yaml:"ref"`
+ Content string `json:"content" yaml:"-"`
+ Fields []*IssueFormField `json:"body" yaml:"body"`
+ FileName string `json:"file_name" yaml:"-"`
}
-// Valid checks whether an IssueTemplate is considered valid, e.g. at least name and about
-func (it IssueTemplate) Valid() bool {
- return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != ""
+// IssueTemplateType defines issue template type
+type IssueTemplateType string
+
+const (
+ IssueTemplateTypeMarkdown IssueTemplateType = "md"
+ IssueTemplateTypeYaml IssueTemplateType = "yaml"
+)
+
+// Type returns the type of IssueTemplate, can be "md", "yaml" or empty for known
+func (it IssueTemplate) Type() IssueTemplateType {
+ if it.Name == "config.yaml" || it.Name == "config.yml" {
+ // ignore config.yaml which is a special configuration file
+ return ""
+ }
+ if ext := filepath.Ext(it.FileName); ext == ".md" {
+ return IssueTemplateTypeMarkdown
+ } else if ext == ".yaml" || ext == ".yml" {
+ return "yaml"
+ }
+ return IssueTemplateTypeYaml
}