first-commit

This commit is contained in:
2025-08-25 15:46:12 +08:00
commit f4d95dfff4
5665 changed files with 705359 additions and 0 deletions

View File

@@ -0,0 +1,486 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package template
import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"code.gitea.io/gitea/modules/container"
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 errors.New("'name' is required")
}
if strings.TrimSpace(template.About) == "" {
return errors.New("'about' is required")
}
return nil
}
func validateYaml(template *api.IssueTemplate) error {
if len(template.Fields) == 0 {
return errors.New("'body' is required")
}
ids := make(container.Set[string])
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 := validateBoolItem(position, field.Attributes, "list"); err != nil {
return err
}
if err := validateOptions(field, idx); err != nil {
return err
}
if err := validateDropdownDefault(position, field.Attributes); 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
}
if err := validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required"); err != nil {
return err
}
if required, _ := field.Validations["required"].(bool); required && !field.VisibleOnForm() {
return newErrorPosition(idx, field.Type).Errorf("can not require a hidden field")
}
return nil
}
func validateID(field *api.IssueFormField, idx int, ids container.Set[string]) 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 !ids.Add(field.ID) {
return position.Errorf("'id' should be unique")
}
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"].([]any)
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[string]any)
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 visibility, ok := opt["visible"]; ok {
visibilityList, ok := visibility.([]any)
if !ok {
return position.Errorf("'visible' should be list")
}
for _, visibleType := range visibilityList {
visibleType, ok := visibleType.(string)
if !ok || !(visibleType == "form" || visibleType == "content") {
return position.Errorf("'visible' list can only contain strings of 'form' and 'content'")
}
}
}
if required, ok := opt["required"]; ok {
if _, ok := required.(bool); !ok {
return position.Errorf("'required' should be a bool")
}
// validate if hidden field is required
if visibility, ok := opt["visible"]; ok {
visibilityList, _ := visibility.([]any)
isVisible := false
for _, v := range visibilityList {
if vv, _ := v.(string); vv == "form" {
isVisible = true
break
}
}
if !isVisible {
return position.Errorf("can not require a hidden checkbox")
}
}
}
}
}
return nil
}
func validateStringItem(position errorPosition, m map[string]any, 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]any, 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
}
func validateDropdownDefault(position errorPosition, attributes map[string]any) error {
v, ok := attributes["default"]
if !ok {
return nil
}
defaultValue, ok := v.(int)
if !ok {
return position.Errorf("'default' should be an int")
}
options, ok := attributes["options"].([]any)
if !ok {
// should not happen
return position.Errorf("'options' is required and should be a array")
}
if defaultValue < 0 || defaultValue >= len(options) {
return position.Errorf("the value of 'default' is out of range")
}
return nil
}
type errorPosition string
func (p errorPosition) Errorf(format string, a ...any) 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 == "" || !f.VisibleInContent() {
continue
}
f.WriteTo(builder)
}
return builder.String()
}
type valuedField struct {
*api.IssueFormField
url.Values
}
func (f *valuedField) WriteTo(builder *strings.Builder) {
// write label
if !f.HideLabel() {
_, _ = 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() {
if !option.VisibleInContent() {
continue
}
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 {
if list, ok := f.Attributes["list"].(bool); ok && list {
for _, check := range checkeds {
_, _ = fmt.Fprintf(builder, "- %s\n", check)
}
} else {
_, _ = 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)
}
case api.IssueFormFieldTypeMarkdown:
if value, ok := f.Attributes["value"].(string); ok {
_, _ = 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) HideLabel() bool {
if f.Type == api.IssueFormFieldTypeMarkdown {
return true
}
if label, ok := f.Attributes["hide_label"].(bool); ok {
return label
}
return false
}
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("form-field-" + f.ID))
}
func (f *valuedField) Options() []*valuedOption {
if options, ok := f.Attributes["options"].([]any); 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 any
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[string]any); 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("form-field-"+o.field.ID), ",")
idx := strconv.Itoa(o.index)
return slices.Contains(checks, idx)
case api.IssueFormFieldTypeCheckboxes:
return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on"
}
return false
}
func (o *valuedOption) VisibleInContent() bool {
if o.field.Type == api.IssueFormFieldTypeCheckboxes {
if vs, ok := o.data.(map[string]any); ok {
if vl, ok := vs["visible"].([]any); ok {
for _, v := range vl {
if vv, _ := v.(string); vv == "content" {
return true
}
}
return false
}
}
}
return true
}
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,964 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package template
import (
"net/url"
"testing"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidate(t *testing.T) {
tests := []struct {
name string
filename string
content string
want *api.IssueTemplate
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: "dropdown invalid list",
content: `
name: "test"
about: "this is about"
body:
- type: "dropdown"
id: "1"
attributes:
label: "a"
list: "on"
`,
wantErr: "body[0](dropdown): 'list' 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",
},
{
name: "field is required but hidden",
content: `
name: "test"
about: "this is about"
body:
- type: "input"
id: "1"
attributes:
label: "a"
validations:
required: true
visible: [content]
`,
wantErr: "body[0](input): can not require a hidden field",
},
{
name: "checkboxes is required but hidden",
content: `
name: "test"
about: "this is about"
body:
- type: checkboxes
id: "1"
attributes:
label: Label of checkboxes
description: Description of checkboxes
options:
- label: Option 1
required: false
- label: Required and hidden
required: true
visible: [content]
`,
wantErr: "body[0](checkboxes), option[1]: can not require a hidden checkbox",
},
{
name: "dropdown default is not an integer",
content: `
name: "test"
about: "this is about"
body:
- type: dropdown
id: "1"
attributes:
label: Label of dropdown
description: Description of dropdown
multiple: true
options:
- Option 1 of dropdown
- Option 2 of dropdown
- Option 3 of dropdown
default: "def"
validations:
required: true
`,
wantErr: "body[0](dropdown): 'default' should be an int",
},
{
name: "dropdown default is out of range",
content: `
name: "test"
about: "this is about"
body:
- type: dropdown
id: "1"
attributes:
label: Label of dropdown
description: Description of dropdown
multiple: true
options:
- Option 1 of dropdown
- Option 2 of dropdown
- Option 3 of dropdown
default: 3
validations:
required: true
`,
wantErr: "body[0](dropdown): the value of 'default' is out of range",
},
{
name: "dropdown without default is valid",
content: `
name: "test"
about: "this is about"
body:
- type: dropdown
id: "1"
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
`,
want: &api.IssueTemplate{
Name: "test",
About: "this is about",
Fields: []*api.IssueFormField{
{
Type: "dropdown",
ID: "1",
Attributes: map[string]any{
"label": "Label of dropdown",
"description": "Description of dropdown",
"multiple": true,
"options": []any{
"Option 1 of dropdown",
"Option 2 of dropdown",
"Option 3 of dropdown",
},
},
Validations: map[string]any{
"required": true,
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
},
},
FileName: "test.yaml",
},
wantErr: "",
},
{
name: "valid",
content: `
name: Name
title: Title
about: About
labels: ["label1", "label2"]
assignees: ["user1", "user2"]
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
default: 1
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: Hidden Option 3 of checkboxes
visible: [content]
- label: Required but not submitted
required: true
visible: [form]
`,
want: &api.IssueTemplate{
Name: "Name",
Title: "Title",
About: "About",
Labels: []string{"label1", "label2"},
Assignees: []string{"user1", "user2"},
Ref: "Ref",
Fields: []*api.IssueFormField{
{
Type: "markdown",
ID: "id1",
Attributes: map[string]any{
"value": "Value of the markdown",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
},
{
Type: "textarea",
ID: "id2",
Attributes: map[string]any{
"label": "Label of textarea",
"description": "Description of textarea",
"placeholder": "Placeholder of textarea",
"value": "Value of textarea",
"render": "bash",
},
Validations: map[string]any{
"required": true,
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
},
{
Type: "input",
ID: "id3",
Attributes: map[string]any{
"label": "Label of input",
"description": "Description of input",
"placeholder": "Placeholder of input",
"value": "Value of input",
},
Validations: map[string]any{
"required": true,
"is_number": true,
"regex": "[a-zA-Z0-9]+",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
},
{
Type: "dropdown",
ID: "id4",
Attributes: map[string]any{
"label": "Label of dropdown",
"description": "Description of dropdown",
"multiple": true,
"options": []any{
"Option 1 of dropdown",
"Option 2 of dropdown",
"Option 3 of dropdown",
},
"default": 1,
},
Validations: map[string]any{
"required": true,
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
},
{
Type: "checkboxes",
ID: "id5",
Attributes: map[string]any{
"label": "Label of checkboxes",
"description": "Description of checkboxes",
"options": []any{
map[string]any{"label": "Option 1 of checkboxes", "required": true},
map[string]any{"label": "Option 2 of checkboxes", "required": false},
map[string]any{"label": "Hidden Option 3 of checkboxes", "visible": []string{"content"}},
map[string]any{"label": "Required but not submitted", "required": true, "visible": []string{"form"}},
},
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent},
},
},
FileName: "test.yaml",
},
wantErr: "",
},
{
name: "single label",
content: `
name: Name
title: Title
about: About
labels: label1
ref: Ref
body:
- type: markdown
id: id1
attributes:
value: Value of the markdown shown in form
- type: markdown
id: id2
attributes:
value: Value of the markdown shown in created issue
visible: [content]
`,
want: &api.IssueTemplate{
Name: "Name",
Title: "Title",
About: "About",
Labels: []string{"label1"},
Ref: "Ref",
Fields: []*api.IssueFormField{
{
Type: "markdown",
ID: "id1",
Attributes: map[string]any{
"value": "Value of the markdown shown in form",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
},
{
Type: "markdown",
ID: "id2",
Attributes: map[string]any{
"value": "Value of the markdown shown in created issue",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleContent},
},
},
FileName: "test.yaml",
},
wantErr: "",
},
{
name: "comma-delimited labels",
content: `
name: Name
title: Title
about: About
labels: label1,label2,,label3 ,,
ref: Ref
body:
- type: markdown
id: id1
attributes:
value: Value of the markdown
`,
want: &api.IssueTemplate{
Name: "Name",
Title: "Title",
About: "About",
Labels: []string{"label1", "label2", "label3"},
Ref: "Ref",
Fields: []*api.IssueFormField{
{
Type: "markdown",
ID: "id1",
Attributes: map[string]any{
"value": "Value of the markdown",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
},
},
FileName: "test.yaml",
},
wantErr: "",
},
{
name: "empty string as labels",
content: `
name: Name
title: Title
about: About
labels: ''
ref: Ref
body:
- type: markdown
id: id1
attributes:
value: Value of the markdown
`,
want: &api.IssueTemplate{
Name: "Name",
Title: "Title",
About: "About",
Labels: nil,
Ref: "Ref",
Fields: []*api.IssueFormField{
{
Type: "markdown",
ID: "id1",
Attributes: map[string]any{
"value": "Value of the markdown",
},
Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm},
},
},
FileName: "test.yaml",
},
wantErr: "",
},
{
name: "comma delimited labels in markdown",
filename: "test.md",
content: `---
name: Name
title: Title
about: About
labels: label1,label2,,label3 ,,
ref: Ref
---
Content
`,
want: &api.IssueTemplate{
Name: "Name",
Title: "Title",
About: "About",
Labels: []string{"label1", "label2", "label3"},
Ref: "Ref",
Fields: nil,
Content: "Content\n",
FileName: "test.md",
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filename := "test.yaml"
if tt.filename != "" {
filename = tt.filename
}
tmpl, err := unmarshal(filename, []byte(tt.content))
require.NoError(t, err)
if tt.wantErr != "" {
require.EqualError(t, Validate(tmpl), tt.wantErr)
} else {
require.NoError(t, Validate(tmpl))
want, _ := json.Marshal(tt.want)
got, _ := json.Marshal(tmpl)
require.JSONEq(t, string(want), string(got))
}
})
}
}
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 shown in form
- type: markdown
id: id2
attributes:
value: Value of the markdown shown in created issue
visible: [content]
- type: textarea
id: id3
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: id4
attributes:
label: Label of input
description: Description of input
placeholder: Placeholder of input
value: Value of input
hide_label: true
validations:
required: true
is_number: true
regex: "[a-zA-Z0-9]+"
- type: dropdown
id: id5
attributes:
label: Label of dropdown (one line)
description: Description of dropdown
multiple: true
options:
- Option 1 of dropdown
- Option 2 of dropdown
- Option 3 of dropdown
validations:
required: true
- type: dropdown
id: id6
attributes:
label: Label of dropdown (list)
description: Description of dropdown
multiple: true
list: true
options:
- Option 1 of dropdown
- Option 2 of dropdown
- Option 3 of dropdown
validations:
required: true
- type: checkboxes
id: id7
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
visible: [form]
- label: Hidden Option of checkboxes
visible: [content]
`,
values: map[string][]string{
"form-field-id3": {"Value of id3"},
"form-field-id4": {"Value of id4"},
"form-field-id5": {"0,1"},
"form-field-id6": {"1,2"},
"form-field-id7-0": {"on"},
"form-field-id7-2": {"on"},
},
},
want: `Value of the markdown shown in created issue
### Label of textarea
` + "```bash\nValue of id3\n```" + `
Value of id4
### Label of dropdown (one line)
Option 1 of dropdown, Option 2 of dropdown
### Label of dropdown (list)
- Option 2 of dropdown
- Option 3 of dropdown
### Label of checkboxes
- [x] Option 1 of checkboxes
- [ ] Option 2 of checkboxes
- [ ] Hidden Option 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 {
assert.Equal(t, tt.want, got)
}
})
}
}
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) {
got := minQuotes(tt.args.value)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,147 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package template
import (
"fmt"
"io"
"path"
"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"
"code.gitea.io/gitea/modules/util"
"gopkg.in/yaml.v3"
)
// 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, path.Join(dir, entry.Name())) // Filepaths in Git are ALWAYS '/' separated do not use filepath here
}
// 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 {
if templateBody, err := markdown.ExtractMetadata(string(content), it); err != nil {
// The only thing we know here is that we can't extract metadata from the content,
// it's hard to tell if metadata doesn't exist or metadata isn't valid.
// There's an example template:
//
// ---
// # Title
// ---
// Content
//
// It could be a valid markdown with two horizontal lines, or an invalid markdown with wrong metadata.
it.Content = string(content)
it.Name = path.Base(it.FileName) // paths in Git are always '/' separated - do not use filepath!
it.About = util.EllipsisDisplayString(it.Content, 80)
} else {
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 {
// set default id value
if v.ID == "" {
v.ID = strconv.Itoa(i)
}
// set default visibility
if v.Visible == nil {
v.Visible = []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm}
// markdown is not submitted by default
if v.Type != api.IssueFormFieldTypeMarkdown {
v.Visible = append(v.Visible, api.IssueFormFieldVisibleContent)
}
}
}
}
return it, nil
}