aboutsummaryrefslogtreecommitdiffstats
path: root/modules/issue/template
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/issue/template
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/issue/template')
-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
3 files changed, 1162 insertions, 0 deletions
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
+}