-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
144
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
-
181
-
182
-
183
-
184
-
185
-
186
-
187
-
188
-
189
-
190
-
191
-
192
-
193
-
194
-
195
-
196
-
197
-
198
-
199
-
200
-
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
212
-
213
-
214
-
215
-
216
-
217
-
218
-
219
-
220
-
221
-
222
-
223
-
224
-
225
-
226
-
227
-
228
-
229
-
230
-
231
-
232
-
233
-
234
-
235
-
236
-
237
-
238
-
239
-
240
-
241
-
242
-
243
-
244
-
245
-
246
-
247
-
248
-
249
-
250
-
251
-
252
-
253
-
254
-
255
-
256
-
257
-
258
-
259
-
260
-
261
-
262
-
263
-
264
-
265
-
266
-
267
-
268
-
269
-
270
-
271
-
272
-
273
-
274
-
275
-
276
-
277
-
278
-
279
-
280
-
281
-
282
-
283
-
284
-
285
-
286
-
287
-
288
-
289
-
290
-
291
-
292
-
293
-
294
-
295
-
296
-
297
-
298
-
299
-
300
-
301
-
302
-
303
-
304
-
305
-
306
-
307
-
308
-
309
-
310
-
311
-
312
-
313
-
314
-
315
-
316
-
317
-
318
-
319
-
320
-
321
-
322
-
323
-
324
-
325
-
326
-
327
-
328
-
329
-
330
-
331
-
332
-
333
-
334
-
335
-
336
-
337
-
338
-
339
-
340
-
341
-
342
-
343
-
344
-
345
-
346
-
347
-
348
-
349
-
350
-
351
-
352
-
353
-
354
-
355
-
356
-
357
-
358
-
359
-
360
-
361
-
362
-
363
-
364
-
365
-
366
-
367
-
368
-
369
-
370
-
371
-
372
-
373
-
374
-
375
-
376
-
377
-
378
-
379
-
380
-
381
-
382
-
383
-
384
-
385
-
386
-
387
-
388
-
389
-
390
-
391
// 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"].([]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[string]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[string]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
}