forgejo/modules/issue/template/template.go
6543 ff8f7a7a0d
Extend issue template yaml engine (#29274)
Add new option:

`visible`: witch can hide a specific field of the form or the created
content afterwards

It is a string array witch can contain `form` and `content`. If only
`form` is present, it wont show up in the created issue afterwards and
the other way around. By default it sets both except for markdown

As they are optional and github don't have any similar thing, it is non
breaking and also do not conflict with it.

With this you can:
- define "post issue creation" elements like a TODO list to track an
issue state
- make sure to have a checkbox that reminds the user to check for a
thing but dont have it in the created issue afterwards
- define markdown for the created issue (was the downside of using yaml
instead of md in the past)
 - ...

## Demo

```yaml
name: New Contribution
description: External Contributor creating a pull

body:
- type: checkboxes
  id: extern-todo
  visible: [form]
  attributes:
    label: Contribution Guidelines
    options:
      - label: I checked there exist no similar feature to be extended
        required: true
      - label: I did read the CONTRIBUTION.MD
        required: true
- type: checkboxes
  id: intern-todo
  visible: [content]
  attributes:
    label: Maintainer Check-List
    options:
      - label: Does this pull follow the KISS principe
      - label: Checked if internal bord was notifyed
# ....
```
[Demo
Video](https://cloud.obermui.de/s/tm34fSAbJp9qw9z/download/vid-20240220-152751.mkv)

---
*Sponsored by Kithara Software GmbH*

---------

Co-authored-by: John Olheiser <john.olheiser@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
(cherry picked from commit 77e29e0c39392f142627303bd798fb55258072b2)
2024-03-06 12:10:47 +08:00

456 lines
12 KiB
Go

// 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
}
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
}
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 {
_, _ = 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(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
}
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
}