diff --git a/docs/.gitignore b/docs/.gitignore index 9cd1408bd2..271adbb1da 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,3 +2,6 @@ public/ templates/swagger/v1_json.tmpl themes/ resources/ + +# Temporary lock file while building +/.hugo_build.lock diff --git a/docs/content/doc/usage/issue-pull-request-templates.en-us.md b/docs/content/doc/usage/issue-pull-request-templates.en-us.md index 38cbfbf8a5..f1d577e77b 100644 --- a/docs/content/doc/usage/issue-pull-request-templates.en-us.md +++ b/docs/content/doc/usage/issue-pull-request-templates.en-us.md @@ -25,51 +25,53 @@ main branch of the repository so that they can autopopulate the form when users creating issues and pull requests. This will cut down on the initial back and forth of getting some clarifying details. +Additionally, the New Issue page URL can be suffixed with `?title=Issue+Title&body=Issue+Text` and the form will be populated with those strings. Those strings will be used instead of the template if there is one. + +## File names + Possible file names for issue templates: - `ISSUE_TEMPLATE.md` +- `ISSUE_TEMPLATE.yaml` +- `ISSUE_TEMPLATE.yml` - `issue_template.md` +- `issue_template.yaml` +- `issue_template.yml` - `.gitea/ISSUE_TEMPLATE.md` +- `.gitea/ISSUE_TEMPLATE.yaml` +- `.gitea/ISSUE_TEMPLATE.yml` +- `.gitea/issue_template.md` +- `.gitea/issue_template.yaml` - `.gitea/issue_template.md` - `.github/ISSUE_TEMPLATE.md` +- `.github/ISSUE_TEMPLATE.yaml` +- `.github/ISSUE_TEMPLATE.yml` - `.github/issue_template.md` +- `.github/issue_template.yaml` +- `.github/issue_template.yml` Possible file names for PR templates: - `PULL_REQUEST_TEMPLATE.md` +- `PULL_REQUEST_TEMPLATE.yaml` +- `PULL_REQUEST_TEMPLATE.yml` - `pull_request_template.md` +- `pull_request_template.yaml` +- `pull_request_template.yml` - `.gitea/PULL_REQUEST_TEMPLATE.md` +- `.gitea/PULL_REQUEST_TEMPLATE.yaml` +- `.gitea/PULL_REQUEST_TEMPLATE.yml` - `.gitea/pull_request_template.md` +- `.gitea/pull_request_template.yaml` +- `.gitea/pull_request_template.yml` - `.github/PULL_REQUEST_TEMPLATE.md` +- `.github/PULL_REQUEST_TEMPLATE.yaml` +- `.github/PULL_REQUEST_TEMPLATE.yml` - `.github/pull_request_template.md` +- `.github/pull_request_template.yaml` +- `.github/pull_request_template.yml` -Possible file names for PR default merge message templates: - -- `.gitea/default_merge_message/MERGE_TEMPLATE.md` -- `.gitea/default_merge_message/REBASE_TEMPLATE.md` -- `.gitea/default_merge_message/REBASE-MERGE_TEMPLATE.md` -- `.gitea/default_merge_message/SQUASH_TEMPLATE.md` -- `.gitea/default_merge_message/MANUALLY-MERGED_TEMPLATE.md` -- `.gitea/default_merge_message/REBASE-UPDATE-ONLY_TEMPLATE.md` - -You can use the following variables enclosed in `${}` inside these templates which follow [os.Expand](https://pkg.go.dev/os#Expand) syntax: - -- BaseRepoOwnerName: Base repository owner name of this pull request -- BaseRepoName: Base repository name of this pull request -- BaseBranch: Base repository target branch name of this pull request -- HeadRepoOwnerName: Head repository owner name of this pull request -- HeadRepoName: Head repository name of this pull request -- HeadBranch: Head repository branch name of this pull request -- PullRequestTitle: Pull request's title -- PullRequestDescription: Pull request's description -- PullRequestPosterName: Pull request's poster name -- PullRequestIndex: Pull request's index number -- PullRequestReference: Pull request's reference char with index number. i.e. #1, !2 -- ClosingIssues: return a string contains all issues which will be closed by this pull request i.e. `close #1, close #2` - -Additionally, the New Issue page URL can be suffixed with `?title=Issue+Title&body=Issue+Text` and the form will be populated with those strings. Those strings will be used instead of the template if there is one. - -## Issue Template Directory +## Directory names Alternatively, users can create multiple issue templates inside a special directory and allow users to choose one that more specifically addresses their problem. @@ -85,7 +87,9 @@ Possible directory names for issue templates: - `.gitlab/ISSUE_TEMPLATE` - `.gitlab/issue_template` -Inside the directory can be multiple markdown (`.md`) issue templates of the form +Inside the directory can be multiple markdown (`.md`) or yaml (`.yaml`/`.yml`) issue templates of the form. + +## Syntax for markdown template ```md --- @@ -108,3 +112,158 @@ In the above example, when a user is presented with the list of issues they can `This template is for testing!`. When submitting an issue with the above example, the issue title would be pre-populated with `[TEST] ` while the issue body would be pre-populated with `This is the template!`. The issue would also be assigned two labels, `bug` and `help needed`, and the issue will have a reference to `main`. + +## Syntax for yaml template + +This example YAML configuration file defines an issue form using several inputs to report a bug. + +```yaml +name: Bug Report +about: File a bug report +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: input + id: contact + attributes: + label: Contact Details + description: How can we get in touch with you if we need more info? + placeholder: ex. email@example.com + validations: + required: false + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: dropdown + id: version + attributes: + label: Version + description: What version of our software are you running? + options: + - 1.0.2 (Default) + - 1.0.3 (Edge) + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com) + options: + - label: I agree to follow this project's Code of Conduct + required: true +``` + +### Markdown + +You can use a `markdown` element to display Markdown in your form that provides extra context to the user, but is not submitted. + +Attributes: + +| Key | Description | Required | Type | Default | Valid values | +|-------|--------------------------------------------------------------|----------|--------|---------|--------------| +| value | The text that is rendered. Markdown formatting is supported. | Required | String | - | - | + +### Textarea + +You can use a `textarea` element to add a multi-line text field to your form. Contributors can also attach files in `textarea` fields. + +Attributes: + +| Key | Description | Required | Type | Default | Valid values | +|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|--------------|---------------------------| +| label | A brief description of the expected user input, which is also displayed in the form. | Required | String | - | - | +| description | A description of the text area to provide context or guidance, which is displayed in the form. | Optional | String | Empty String | - | +| placeholder | A semi-opaque placeholder that renders in the text area when empty. | Optional | String | Empty String | - | +| value | Text that is pre-filled in the text area. | Optional | String | - | - | +| render | If a value is provided, submitted text will be formatted into a codeblock. When this key is provided, the text area will not expand for file attachments or Markdown editing. | Optional | String | - | Languages known to Gitea. | + +Validations: + +| Key | Description | Required | Type | Default | Valid values | +|----------|------------------------------------------------------|----------|---------|---------|--------------| +| required | Prevents form submission until element is completed. | Optional | Boolean | false | - | + +### Input + +You can use an `input` element to add a single-line text field to your form. + +Attributes: + +| Key | Description | Required | Type | Default | Valid values | +|-------------|--------------------------------------------------------------------------------------------|----------|--------|--------------|--------------| +| label | A brief description of the expected user input, which is also displayed in the form. | Required | String | - | - | +| description | A description of the field to provide context or guidance, which is displayed in the form. | Optional | String | Empty String | - | +| placeholder | A semi-transparent placeholder that renders in the field when empty. | Optional | String | Empty String | - | +| value | Text that is pre-filled in the field. | Optional | String | - | - | + +Validations: + +| Key | Description | Required | Type | Default | Valid values | +|-----------|--------------------------------------------------------------------------------------------------|----------|---------|---------|--------------------------------------------------------------------------| +| required | Prevents form submission until element is completed. | Optional | Boolean | false | - | +| is_number | Prevents form submission until element is filled with a number. | Optional | Boolean | false | - | +| regex | Prevents form submission until element is filled with a value that match the regular expression. | Optional | String | - | a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) | + +### Dropdown + +You can use a `dropdown` element to add a dropdown menu in your form. + +Attributes: + +| Key | Description | Required | Type | Default | Valid values | +|-------------|-----------------------------------------------------------------------------------------------------|----------|--------------|--------------|--------------| +| label | A brief description of the expected user input, which is displayed in the form. | Required | String | - | - | +| description | A description of the dropdown to provide extra context or guidance, which is displayed in the form. | Optional | String | Empty String | - | +| multiple | Determines if the user can select more than one option. | Optional | Boolean | false | - | +| options | An array of options the user can choose from. Cannot be empty and all choices must be distinct. | Required | String array | - | - | + +Validations: + +| Key | Description | Required | Type | Default | Valid values | +|----------|------------------------------------------------------|----------|---------|---------|--------------| +| required | Prevents form submission until element is completed. | Optional | Boolean | false | - | + +### Checkboxes + +You can use the `checkboxes` element to add a set of checkboxes to your form. + +Attributes: + +| Key | Description | Required | Type | Default | Valid values | +|-------------|-------------------------------------------------------------------------------------------------------|----------|--------|--------------|--------------| +| label | A brief description of the expected user input, which is displayed in the form. | Required | String | - | - | +| description | A description of the set of checkboxes, which is displayed in the form. Supports Markdown formatting. | Optional | String | Empty String | - | +| options | An array of checkboxes that the user can select. For syntax, see below. | Required | Array | - | - | + +For each value in the options array, you can set the following keys. + +| Key | Description | Required | Type | Default | Options | +|----------|------------------------------------------------------------------------------------------------------------------------------------------|----------|---------|---------|---------| +| label | The identifier for the option, which is displayed in the form. Markdown is supported for bold or italic text formatting, and hyperlinks. | Required | String | - | - | +| required | Prevents form submission until element is completed. | Optional | Boolean | false | - | diff --git a/docs/content/doc/usage/merge-message-templates.en-us.md b/docs/content/doc/usage/merge-message-templates.en-us.md new file mode 100644 index 0000000000..c30ac1bfeb --- /dev/null +++ b/docs/content/doc/usage/merge-message-templates.en-us.md @@ -0,0 +1,48 @@ +--- +date: "2022-08-31T17:35:40+08:00" +title: "Usage: Merge Message templates" +slug: "merge-message-templates" +weight: 15 +toc: false +draft: false +menu: + sidebar: + parent: "usage" + name: "Merge Message templates" + weight: 15 + identifier: "merge-message-templates" +--- + +# Merge Message templates + +**Table of Contents** + +{{< toc >}} + +## File names + +Possible file names for PR default merge message templates: + +- `.gitea/default_merge_message/MERGE_TEMPLATE.md` +- `.gitea/default_merge_message/REBASE_TEMPLATE.md` +- `.gitea/default_merge_message/REBASE-MERGE_TEMPLATE.md` +- `.gitea/default_merge_message/SQUASH_TEMPLATE.md` +- `.gitea/default_merge_message/MANUALLY-MERGED_TEMPLATE.md` +- `.gitea/default_merge_message/REBASE-UPDATE-ONLY_TEMPLATE.md` + +## Variables + +You can use the following variables enclosed in `${}` inside these templates which follow [os.Expand](https://pkg.go.dev/os#Expand) syntax: + +- BaseRepoOwnerName: Base repository owner name of this pull request +- BaseRepoName: Base repository name of this pull request +- BaseBranch: Base repository target branch name of this pull request +- HeadRepoOwnerName: Head repository owner name of this pull request +- HeadRepoName: Head repository name of this pull request +- HeadBranch: Head repository branch name of this pull request +- PullRequestTitle: Pull request's title +- PullRequestDescription: Pull request's description +- PullRequestPosterName: Pull request's poster name +- PullRequestIndex: Pull request's index number +- PullRequestReference: Pull request's reference char with index number. i.e. #1, !2 +- ClosingIssues: return a string contains all issues which will be closed by this pull request i.e. `close #1, close #2` diff --git a/modules/context/repo.go b/modules/context/repo.go index 5404acc05a..6a336c45f7 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -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 } diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go new file mode 100644 index 0000000000..a4c0fb5aa6 --- /dev/null +++ b/modules/issue/template/template.go @@ -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 +} diff --git a/modules/issue/template/template_test.go b/modules/issue/template/template_test.go new file mode 100644 index 0000000000..883e1e0780 --- /dev/null +++ b/modules/issue/template/template_test.go @@ -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) + } + }) + } +} diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go new file mode 100644 index 0000000000..e695d1e1cc --- /dev/null +++ b/modules/issue/template/unmarshal.go @@ -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 +} diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go index f525777a54..939646f8fd 100644 --- a/modules/markup/markdown/meta_test.go +++ b/modules/markup/markdown/meta_test.go @@ -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)) }) } diff --git a/modules/structs/issue.go b/modules/structs/issue.go index c72487fe4d..27ec81f728 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -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 } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f900e90e7f..ba647771e3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1231,6 +1231,8 @@ issues.new.add_reviewer_title = Request review issues.choose.get_started = Get Started issues.choose.blank = Default issues.choose.blank_about = Create an issue from default template. +issues.choose.ignore_invalid_templates = Invalid templates have been ignored +issues.choose.invalid_templates = %v invalid template(s) found issues.no_ref = No Branch/Tag Specified issues.create = Create Issue issues.new_label = New Label diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index f34d3a5203..e35af31724 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -784,7 +784,11 @@ func CompareDiff(ctx *context.Context) { ctx.Data["IsRepoToolbarCommits"] = true ctx.Data["IsDiffCompare"] = true ctx.Data["RequireTribute"] = true - setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates) + templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) + + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } // If a template content is set, prepend the "content". In this case that's only // applicable if you have one commit to compare and that commit has a message. diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3f14416e48..06e003bff2 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -10,11 +10,10 @@ import ( stdCtx "context" "errors" "fmt" - "io" "math/big" "net/http" "net/url" - "path" + "sort" "strconv" "strings" "time" @@ -35,6 +34,7 @@ import ( "code.gitea.io/gitea/modules/convert" "code.gitea.io/gitea/modules/git" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" @@ -45,6 +45,7 @@ import ( "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" comment_service "code.gitea.io/gitea/services/comments" "code.gitea.io/gitea/services/forms" @@ -70,11 +71,23 @@ const ( // IssueTemplateCandidates issue templates var IssueTemplateCandidates = []string{ "ISSUE_TEMPLATE.md", + "ISSUE_TEMPLATE.yaml", + "ISSUE_TEMPLATE.yml", "issue_template.md", + "issue_template.yaml", + "issue_template.yml", ".gitea/ISSUE_TEMPLATE.md", + ".gitea/ISSUE_TEMPLATE.yaml", + ".gitea/ISSUE_TEMPLATE.yml", + ".gitea/issue_template.md", + ".gitea/issue_template.yaml", ".gitea/issue_template.md", ".github/ISSUE_TEMPLATE.md", + ".github/ISSUE_TEMPLATE.yaml", + ".github/ISSUE_TEMPLATE.yml", ".github/issue_template.md", + ".github/issue_template.yaml", + ".github/issue_template.yml", } // MustAllowUserComment checks to make sure if an issue is locked. @@ -722,81 +735,62 @@ func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull return labels } -func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (string, bool) { - if ctx.Repo.Commit == nil { - var err error - ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { - return "", false - } +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) map[string]error { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + return nil } - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(filename) - if err != nil { - return "", false - } - if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize { - return "", false - } - r, err := entry.Blob().DataAsync() - if err != nil { - return "", false - } - defer r.Close() - bytes, err := io.ReadAll(r) - if err != nil { - return "", false - } - return string(bytes), true -} - -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) { - templateCandidates := make([]string, 0, len(possibleFiles)) - if ctx.FormString("template") != "" { - for _, dirName := range possibleDirs { - templateCandidates = append(templateCandidates, path.Join(dirName, ctx.FormString("template"))) - } + templateCandidates := make([]string, 0, 1+len(possibleFiles)) + if t := ctx.FormString("template"); t != "" { + templateCandidates = append(templateCandidates, t) } templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback - for _, filename := range templateCandidates { - templateContent, found := getFileContentFromDefaultBranch(ctx, filename) - if found { - var meta api.IssueTemplate - templateBody, err := markdown.ExtractMetadata(templateContent, &meta) - if err != nil { - log.Debug("could not extract metadata from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err) - ctx.Data[ctxDataKey] = templateContent - return - } - ctx.Data[issueTemplateTitleKey] = meta.Title - ctx.Data[ctxDataKey] = templateBody - labelIDs := make([]string, 0, len(meta.Labels)) - if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { - ctx.Data["Labels"] = repoLabels - if ctx.Repo.Owner.IsOrganization() { - if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil { - ctx.Data["OrgLabels"] = orgLabels - repoLabels = append(repoLabels, orgLabels...) - } - } - for _, metaLabel := range meta.Labels { - for _, repoLabel := range repoLabels { - if strings.EqualFold(repoLabel.Name, metaLabel) { - repoLabel.IsChecked = true - labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) - break - } + templateErrs := map[string]error{} + for _, filename := range templateCandidates { + if ok, _ := commit.HasFile(filename); !ok { + continue + } + template, err := issue_template.UnmarshalFromCommit(commit, filename) + if err != nil { + templateErrs[filename] = err + continue + } + ctx.Data[issueTemplateTitleKey] = template.Title + ctx.Data[ctxDataKey] = template.Content + + if template.Type() == api.IssueTemplateTypeYaml { + ctx.Data["Fields"] = template.Fields + ctx.Data["TemplateFile"] = template.FileName + } + labelIDs := make([]string, 0, len(template.Labels)) + if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { + ctx.Data["Labels"] = repoLabels + if ctx.Repo.Owner.IsOrganization() { + if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil { + ctx.Data["OrgLabels"] = orgLabels + repoLabels = append(repoLabels, orgLabels...) + } + } + + for _, metaLabel := range template.Labels { + for _, repoLabel := range repoLabels { + if strings.EqualFold(repoLabel.Name, metaLabel) { + repoLabel.IsChecked = true + labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) + break } } } - ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 - ctx.Data["label_ids"] = strings.Join(labelIDs, ",") - ctx.Data["Reference"] = meta.Ref - ctx.Data["RefEndName"] = git.RefEndName(meta.Ref) - return } + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 + ctx.Data["label_ids"] = strings.Join(labelIDs, ",") + ctx.Data["Reference"] = template.Ref + ctx.Data["RefEndName"] = git.RefEndName(template.Ref) + return templateErrs } + return templateErrs } // NewIssue render creating issue page @@ -845,24 +839,62 @@ func NewIssue(ctx *context.Context) { } RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) - setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates) + + _, templateErrs := ctx.IssueTemplatesErrorsFromDefaultBranch() + if errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates); len(errs) > 0 { + for k, v := range errs { + templateErrs[k] = v + } + } if ctx.Written() { return } + if len(templateErrs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + } + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) ctx.HTML(http.StatusOK, tplIssueNew) } +func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) string { + var files []string + for k := range errs { + files = append(files, k) + } + sort.Strings(files) // keep the output stable + + var lines []string + for _, file := range files { + lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file])) + } + + flashError, err := ctx.RenderToString(tplAlertDetails, map[string]interface{}{ + "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"), + "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)), + "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")), + }) + if err != nil { + log.Debug("render flash error: %v", err) + flashError = ctx.Tr("repo.issues.choose.ignore_invalid_templates") + } + return flashError +} + // NewIssueChooseTemplate render creating issue from template page func NewIssueChooseTemplate(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - issueTemplates := ctx.IssueTemplatesFromDefaultBranch() + issueTemplates, errs := ctx.IssueTemplatesErrorsFromDefaultBranch() ctx.Data["IssueTemplates"] = issueTemplates + if len(errs) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true) + } + if len(issueTemplates) == 0 { // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters. ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.HTMLURL(), ctx.Req.URL.RawQuery), http.StatusSeeOther) @@ -1031,6 +1063,13 @@ func NewIssuePost(ctx *context.Context) { return } + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + issue := &issues_model.Issue{ RepoID: repo.ID, Repo: repo, @@ -1038,7 +1077,7 @@ func NewIssuePost(ctx *context.Context) { PosterID: ctx.Doer.ID, Poster: ctx.Doer, MilestoneID: milestoneID, - Content: form.Content, + Content: content, Ref: form.Ref, } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 9b2c7c02cb..aa2c4cdb53 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -30,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" @@ -58,11 +59,23 @@ const ( var pullRequestTemplateCandidates = []string{ "PULL_REQUEST_TEMPLATE.md", + "PULL_REQUEST_TEMPLATE.yaml", + "PULL_REQUEST_TEMPLATE.yml", "pull_request_template.md", + "pull_request_template.yaml", + "pull_request_template.yml", ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/PULL_REQUEST_TEMPLATE.yaml", + ".gitea/PULL_REQUEST_TEMPLATE.yml", ".gitea/pull_request_template.md", + ".gitea/pull_request_template.yaml", + ".gitea/pull_request_template.yml", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE.yaml", + ".github/PULL_REQUEST_TEMPLATE.yml", ".github/pull_request_template.md", + ".github/pull_request_template.yaml", + ".github/pull_request_template.yml", } func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { @@ -1194,6 +1207,13 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } + content := form.Content + if filename := ctx.Req.Form.Get("template-file"); filename != "" { + if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil { + content = issue_template.RenderToMarkdown(template, ctx.Req.Form) + } + } + pullIssue := &issues_model.Issue{ RepoID: repo.ID, Repo: repo, @@ -1202,7 +1222,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { Poster: ctx.Doer, MilestoneID: milestoneID, IsPull: true, - Content: form.Content, + Content: content, } pullRequest := &issues_model.PullRequest{ HeadRepoID: ci.HeadRepo.ID, diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl index cf886f529c..b8a04b89a9 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -13,3 +13,8 @@

{{.Flash.InfoMsg | Str2html}}

{{end}} +{{if .Flash.WarningMsg}} +
+

{{.Flash.WarningMsg | Str2html}}

+
+{{end}} diff --git a/templates/repo/diff/compare.tmpl b/templates/repo/diff/compare.tmpl index 5875efaac3..e9f2fc3b60 100644 --- a/templates/repo/diff/compare.tmpl +++ b/templates/repo/diff/compare.tmpl @@ -11,6 +11,14 @@ {{.locale.Tr "action.compare_commits_general"}} {{end}} + {{if .Flash.WarningMsg}} + {{/* + There's alreay a importing of alert.tmpl in new_form.tmpl, + but only the negative message will be displayed within forms for some reasons, see semantic.css:10659. + To avoid repeated negative messages, the importing here if for .Flash.WarningMsg only. + */}} + {{template "base/alert" .}} + {{end}} {{$BaseCompareName := $.BaseName -}} {{- $HeadCompareName := $.HeadRepo.OwnerName -}} {{- if and (eq $.BaseName $.HeadRepo.OwnerName) (ne $.Repository.Name $.HeadRepo.Name) -}} diff --git a/templates/repo/issue/choose.tmpl b/templates/repo/issue/choose.tmpl index bbddd6d9a6..609cd48436 100644 --- a/templates/repo/issue/choose.tmpl +++ b/templates/repo/issue/choose.tmpl @@ -2,6 +2,7 @@
{{template "repo/header" .}}
+ {{template "base/alert" .}} diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index c1ca69dfb3..b70433a966 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,17 +1,34 @@ - -
-
+{{if .Fields}} + + {{range .Fields}} + {{if eq .Type "input"}} + {{template "repo/issue/fields/input" .}} + {{else if eq .Type "markdown"}} + {{template "repo/issue/fields/markdown" .}} + {{else if eq .Type "textarea"}} + {{template "repo/issue/fields/textarea" .}} + {{else if eq .Type "dropdown"}} + {{template "repo/issue/fields/dropdown" .}} + {{else if eq .Type "checkboxes"}} + {{template "repo/issue/fields/checkboxes" .}} + {{end}} + {{end}} +{{else}} + +
+
+
+
+ {{.locale.Tr "loading"}} +
-
- {{.locale.Tr "loading"}} -
-
+{{end}} {{if .IsAttachmentEnabled}}
{{template "repo/upload" .}} diff --git a/templates/repo/issue/fields/checkboxes.tmpl b/templates/repo/issue/fields/checkboxes.tmpl new file mode 100644 index 0000000000..b70334681f --- /dev/null +++ b/templates/repo/issue/fields/checkboxes.tmpl @@ -0,0 +1,12 @@ +
+ {{template "repo/issue/fields/header" .}} + {{$field := .}} + {{range $i, $opt := .Attributes.options}} +
+
+ + +
+
+ {{end}} +
diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl new file mode 100644 index 0000000000..83c2bb4aac --- /dev/null +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -0,0 +1,14 @@ +
+ {{template "repo/issue/fields/header" .}} + {{/* FIXME: required validation */}} + +
diff --git a/templates/repo/issue/fields/header.tmpl b/templates/repo/issue/fields/header.tmpl new file mode 100644 index 0000000000..fb8511b4f0 --- /dev/null +++ b/templates/repo/issue/fields/header.tmpl @@ -0,0 +1,6 @@ +{{if .Attributes.label}} +

{{.Attributes.label}}{{if .Validations.required}}{{end}}

+{{end}} +{{if .Attributes.description}} + {{RenderMarkdownToHtml .Attributes.description}} +{{end}} diff --git a/templates/repo/issue/fields/input.tmpl b/templates/repo/issue/fields/input.tmpl new file mode 100644 index 0000000000..d73354f6d3 --- /dev/null +++ b/templates/repo/issue/fields/input.tmpl @@ -0,0 +1,4 @@ +
+ {{template "repo/issue/fields/header" .}} + +
diff --git a/templates/repo/issue/fields/markdown.tmpl b/templates/repo/issue/fields/markdown.tmpl new file mode 100644 index 0000000000..8236171523 --- /dev/null +++ b/templates/repo/issue/fields/markdown.tmpl @@ -0,0 +1,3 @@ +
+
{{RenderMarkdownToHtml .Attributes.value}}
+
diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl new file mode 100644 index 0000000000..5ad82e2460 --- /dev/null +++ b/templates/repo/issue/fields/textarea.tmpl @@ -0,0 +1,6 @@ +
+ {{template "repo/issue/fields/header" .}} + {{/* FIXME: preview markdown result */}} + {{/* FIXME: required validation for markdown editor */}} + +
diff --git a/templates/repo/issue/new.tmpl b/templates/repo/issue/new.tmpl index f8929b375d..9f46f8fddc 100644 --- a/templates/repo/issue/new.tmpl +++ b/templates/repo/issue/new.tmpl @@ -6,6 +6,14 @@ {{template "repo/issue/navbar" .}}
+ {{if .Flash.WarningMsg}} + {{/* + There's alreay a importing of alert.tmpl in new_form.tmpl, + but only the negative message will be displayed within forms for some reasons, see semantic.css:10659. + To avoid repeated negative messages, the importing here if for .Flash.WarningMsg only. + */}} + {{template "base/alert" .}} + {{end}} {{template "repo/issue/new_form" .}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index cc6399cab4..63b124bd9e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -16584,6 +16584,35 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "IssueFormField": { + "description": "IssueFormField represents a form field", + "type": "object", + "properties": { + "attributes": { + "type": "object", + "additionalProperties": {}, + "x-go-name": "Attributes" + }, + "id": { + "type": "string", + "x-go-name": "ID" + }, + "type": { + "$ref": "#/definitions/IssueFormFieldType" + }, + "validations": { + "type": "object", + "additionalProperties": {}, + "x-go-name": "Validations" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "IssueFormFieldType": { + "type": "string", + "title": "IssueFormFieldType defines issue form field type, can be \"markdown\", \"textarea\", \"input\", \"dropdown\" or \"checkboxes\"", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "IssueLabelsOption": { "description": "IssueLabelsOption a collection of labels", "type": "object", @@ -16608,6 +16637,13 @@ "type": "string", "x-go-name": "About" }, + "body": { + "type": "array", + "items": { + "$ref": "#/definitions/IssueFormField" + }, + "x-go-name": "Fields" + }, "content": { "type": "string", "x-go-name": "Content" diff --git a/web_src/js/features/comp/EasyMDE.js b/web_src/js/features/comp/EasyMDE.js index f1b4b0efc3..015fc35050 100644 --- a/web_src/js/features/comp/EasyMDE.js +++ b/web_src/js/features/comp/EasyMDE.js @@ -93,7 +93,7 @@ export async function createCommentEasyMDE(textarea, easyMDEOptions = {}) { cm.execCommand('delCharBefore'); }, }); - attachTribute(inputField, {mentions: true, emoji: true}); + await attachTribute(inputField, {mentions: true, emoji: true}); attachEasyMDEToElements(easyMDE); return easyMDE; } diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index 11c97ccfb0..2c93ca0342 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -68,9 +68,14 @@ export function initRepoCommentForm() { } (async () => { - const $textarea = $commentForm.find('textarea:not(.review-textarea)'); - const easyMDE = await createCommentEasyMDE($textarea); - initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone')); + for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) { + // Don't initialize EasyMDE for the dormant #edit-content-form + if (textarea.closest('#edit-content-form')) { + continue; + } + const easyMDE = await createCommentEasyMDE(textarea); + initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone')); + } })(); initBranchSelector(); @@ -535,9 +540,13 @@ export function initRepository() { $(this).parent().hide(); const $form = $repoComparePull.find('.pullrequest-form'); - const easyMDE = getAttachedEasyMDE($form.find('textarea.edit_area')); $form.show(); - easyMDE.codemirror.refresh(); + $form.find('textarea.edit_area').each(function() { + const easyMDE = getAttachedEasyMDE($(this)); + if (easyMDE) { + easyMDE.codemirror.refresh(); + } + }); }); } diff --git a/web_src/less/_base.less b/web_src/less/_base.less index 96c039c3b4..bed672a35a 100644 --- a/web_src/less/_base.less +++ b/web_src/less/_base.less @@ -2126,7 +2126,8 @@ table th[data-sortt-desc] { margin-top: inherit; } -.flash-error details code { +.flash-error details code, +.flash-warning details code { display: block; text-align: left; } diff --git a/web_src/less/codemirror/base.less b/web_src/less/codemirror/base.less index 8734457463..db0a93f4c7 100644 --- a/web_src/less/codemirror/base.less +++ b/web_src/less/codemirror/base.less @@ -1,3 +1,7 @@ +.ui .field:not(:last-child) .EasyMDEContainer .editor-statusbar { + margin-bottom: -1em; // when there is a statusbar, the "margin-bottom: 1em" of the "field" is not needed, because the statusbar is likely a blank line +} + .EasyMDEContainer .CodeMirror { color: var(--color-input-text); background-color: var(--color-input-background); diff --git a/web_src/less/features/dropzone.less b/web_src/less/features/dropzone.less index d31aedff3e..ee7a5f1b65 100644 --- a/web_src/less/features/dropzone.less +++ b/web_src/less/features/dropzone.less @@ -6,7 +6,6 @@ padding: 0; border-radius: 4px; min-height: 0; - margin-top: -1em; // we have another `field` above, it's usually an EasyMDE editor with "status bar", so we do not need the space above. .dz-message { margin: 10px 0; }