mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 11:28:24 +00:00 
			
		
		
		
	Support Issue forms and PR forms (#20987)
* feat: extend issue template for yaml * feat: support yaml template * feat: render form to markdown * feat: support yaml template for pr * chore: rename to Fields * feat: template unmarshal * feat: split template * feat: render to markdown * feat: use full name as template file name * chore: remove useless file * feat: use dropdown of fomantic ui * feat: update input style * docs: more comments * fix: render text without render * chore: fix lint error * fix: support use description as about in markdown * fix: add field class in form * chore: generate swagger * feat: validate template * feat: support is_nummber and regex * test: fix broken unit tests * fix: ignore empty body of md template * fix: make multiple easymde editors work in one page * feat: better UI * fix: js error in pr form * chore: generate swagger * feat: support regex validation * chore: generate swagger * fix: refresh each markdown editor * chore: give up required validation * fix: correct issue template candidates * fix: correct checkboxes style * chore: ignore .hugo_build.lock in docs * docs: separate out a new doc for merge templates * docs: introduce syntax of yaml template * feat: show a alert for invalid templates * test: add case for a valid template * fix: correct attributes of required checkbox * fix: add class not-under-easymde for dropzone * fix: use more back-quotes * chore: remove translation in zh-CN * fix EasyMDE statusbar margin * fix: remove repeated blocks * fix: reuse regex for quotes Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		
							
								
								
									
										3
									
								
								docs/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								docs/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -2,3 +2,6 @@ public/ | ||||
| templates/swagger/v1_json.tmpl | ||||
| themes/ | ||||
| resources/ | ||||
|  | ||||
| # Temporary lock file while building | ||||
| /.hugo_build.lock | ||||
|   | ||||
| @@ -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   | -       | | ||||
|   | ||||
							
								
								
									
										48
									
								
								docs/content/doc/usage/merge-message-templates.en-us.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								docs/content/doc/usage/merge-message-templates.en-us.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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` | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										392
									
								
								modules/issue/template/template.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										392
									
								
								modules/issue/template/template.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										645
									
								
								modules/issue/template/template_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										645
									
								
								modules/issue/template/template_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										125
									
								
								modules/issue/template/unmarshal.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								modules/issue/template/unmarshal.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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)) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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, | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -13,3 +13,8 @@ | ||||
| 		<p>{{.Flash.InfoMsg | Str2html}}</p> | ||||
| 	</div> | ||||
| {{end}} | ||||
| {{if .Flash.WarningMsg}} | ||||
| 	<div class="ui warning message flash-warning"> | ||||
| 		<p>{{.Flash.WarningMsg | Str2html}}</p> | ||||
| 	</div> | ||||
| {{end}} | ||||
|   | ||||
| @@ -11,6 +11,14 @@ | ||||
| 			{{.locale.Tr "action.compare_commits_general"}} | ||||
| 		{{end}} | ||||
| 	</h2> | ||||
| 	{{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) -}} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| <div class="page-content repository new issue"> | ||||
| 	{{template "repo/header" .}} | ||||
| 	<div class="ui container"> | ||||
| 		{{template "base/alert" .}} | ||||
| 		<div class="navbar"> | ||||
| 			{{template "repo/issue/navbar" .}} | ||||
| 		</div> | ||||
|   | ||||
| @@ -1,17 +1,34 @@ | ||||
| <div class="ui top tabular menu" data-write="write" data-preview="preview"> | ||||
| 	<a class="active item" data-tab="write">{{.locale.Tr "write"}}</a> | ||||
| 	<a class="item" data-tab="preview" data-url="{{.Repository.HTMLURL}}/markdown" data-context="{{.RepoLink}}">{{.locale.Tr "preview"}}</a> | ||||
| </div> | ||||
| <div class="field"> | ||||
| 	<div class="ui bottom active tab" data-tab="write"> | ||||
| {{if .Fields}} | ||||
| 	<input type="hidden" name="template-file" value="{{.TemplateFile}}"> | ||||
| 	{{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}} | ||||
| 	<div class="ui top tabular menu" data-write="write" data-preview="preview"> | ||||
| 		<a class="active item" data-tab="write">{{.locale.Tr "write"}}</a> | ||||
| 		<a class="item" data-tab="preview" data-url="{{.Repository.HTMLURL}}/markdown" data-context="{{.RepoLink}}">{{.locale.Tr "preview"}}</a> | ||||
| 	</div> | ||||
| 	<div class="field"> | ||||
| 		<div class="ui bottom active tab" data-tab="write"> | ||||
| 		<textarea id="content" class="edit_area js-quick-submit" name="content" tabindex="4" data-id="issue-{{.RepoName}}" data-url="{{.Repository.HTMLURL}}/markdown" data-context="{{.Repo.RepoLink}}"> | ||||
| 			{{- if .BodyQuery}}{{.BodyQuery}}{{else if .IssueTemplate}}{{.IssueTemplate}}{{else if .PullRequestTemplate}}{{.PullRequestTemplate}}{{else}}{{.content}}{{end -}} | ||||
| 		</textarea> | ||||
| 		</div> | ||||
| 		<div class="ui bottom tab markup" data-tab="preview"> | ||||
| 			{{.locale.Tr "loading"}} | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="ui bottom tab markup" data-tab="preview"> | ||||
| 		{{.locale.Tr "loading"}} | ||||
| 	</div> | ||||
| </div> | ||||
| {{end}} | ||||
| {{if .IsAttachmentEnabled}} | ||||
| 	<div class="field"> | ||||
| 		{{template "repo/upload" .}} | ||||
|   | ||||
							
								
								
									
										12
									
								
								templates/repo/issue/fields/checkboxes.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								templates/repo/issue/fields/checkboxes.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| <div class="field"> | ||||
| 	{{template "repo/issue/fields/header" .}} | ||||
| 	{{$field := .}} | ||||
| 	{{range $i, $opt := .Attributes.options}} | ||||
| 		<div class="field"> | ||||
| 			<div class="ui checkbox"> | ||||
| 				<input type="checkbox" name="form-field-{{$field.ID}}-{{$i}}" {{if $opt.required}}readonly checked{{end}}> | ||||
| 				<label>{{$opt.label}}</label> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	{{end}} | ||||
| </div> | ||||
							
								
								
									
										14
									
								
								templates/repo/issue/fields/dropdown.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								templates/repo/issue/fields/dropdown.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| <div class="field"> | ||||
| 	{{template "repo/issue/fields/header" .}} | ||||
| 	{{/* FIXME: required validation */}} | ||||
| 	<div class="ui fluid selection dropdown {{if .Attributes.multiple}}multiple clearable{{end}}"> | ||||
| 		<input type="hidden" name="form-field-{{.ID}}" value="0"> | ||||
| 		<i class="dropdown icon"></i> | ||||
| 		<div class="default text"></div> | ||||
| 		<div class="menu"> | ||||
| 			{{range $i, $opt := .Attributes.options}} | ||||
| 				<div class="item" data-value="{{$i}}">{{$opt}}</div> | ||||
| 			{{end}} | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
							
								
								
									
										6
									
								
								templates/repo/issue/fields/header.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								templates/repo/issue/fields/header.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| {{if .Attributes.label}} | ||||
| 	<h3>{{.Attributes.label}}{{if .Validations.required}}<label class="required"></label>{{end}}</h3> | ||||
| {{end}} | ||||
| {{if .Attributes.description}} | ||||
| 	<span class="help">{{RenderMarkdownToHtml .Attributes.description}}</span> | ||||
| {{end}} | ||||
							
								
								
									
										4
									
								
								templates/repo/issue/fields/input.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								templates/repo/issue/fields/input.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| <div class="field"> | ||||
| 	{{template "repo/issue/fields/header" .}} | ||||
| 	<input type="{{if .Validations.is_number}}number{{else}}text{{end}}" name="form-field-{{.ID}}" placeholder="{{.Attributes.placeholder}}" value="{{.Attributes.value}}" {{if .Validations.required}}required{{end}} {{if .Validations.regex}}pattern="{{.Validations.regex}}" title="{{.Validations.regex}}"{{end}}> | ||||
| </div> | ||||
							
								
								
									
										3
									
								
								templates/repo/issue/fields/markdown.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								templates/repo/issue/fields/markdown.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <div class="field"> | ||||
| 	<div>{{RenderMarkdownToHtml .Attributes.value}}</div> | ||||
| </div> | ||||
							
								
								
									
										6
									
								
								templates/repo/issue/fields/textarea.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								templates/repo/issue/fields/textarea.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <div class="field"> | ||||
| 	{{template "repo/issue/fields/header" .}} | ||||
| 	{{/* FIXME: preview markdown result */}} | ||||
| 	{{/* FIXME: required validation for markdown editor */}} | ||||
| 	<textarea name="form-field-{{.ID}}" placeholder="{{.Attributes.placeholder}}" class="edit_area {{if .Attributes.render}}no-easymde{{end}}" {{if and .Validations.required .Attributes.render}}required{{end}}>{{.Attributes.value}}</textarea> | ||||
| </div> | ||||
| @@ -6,6 +6,14 @@ | ||||
| 			{{template "repo/issue/navbar" .}} | ||||
| 		</div> | ||||
| 		<div class="ui divider"></div> | ||||
| 		{{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" .}} | ||||
| 	</div> | ||||
| </div> | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user