diff options
author | Jason Song <i@wolfogre.com> | 2022-09-02 15:58:49 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-09-02 15:58:49 +0800 |
commit | 84447df4d366324ab81894b028b00fd66be85caf (patch) | |
tree | 5291442a85faccb6bc17b54ca71a53c16530dfe3 /modules | |
parent | b7a4b45ff83dc19febcfb85279215ea6bd224033 (diff) | |
download | gitea-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.go | 71 | ||||
-rw-r--r-- | modules/issue/template/template.go | 392 | ||||
-rw-r--r-- | modules/issue/template/template_test.go | 645 | ||||
-rw-r--r-- | modules/issue/template/unmarshal.go | 125 | ||||
-rw-r--r-- | modules/markup/markdown/meta_test.go | 15 | ||||
-rw-r--r-- | modules/structs/issue.go | 60 |
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 } |