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>
This commit is contained in:
Jason Song 2022-09-02 15:58:49 +08:00 committed by GitHub
parent b7a4b45ff8
commit 84447df4d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1776 additions and 176 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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))
})
}

View file

@ -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
}