123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- // Copyright 2022 The Gitea Authors. All rights reserved.
- // SPDX-License-Identifier: MIT
-
- package template
-
- import (
- "fmt"
- "net/url"
- "regexp"
- "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 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 := 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 := 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 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 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]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
- }
-
- 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 == "" {
- 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
- 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() {
- 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) HideLabel() bool {
- 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(fmt.Sprintf("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(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
- }
|