From 0ed8a3d0d21f56e3e43b8663d66babc754b27599 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sat, 27 Aug 2022 17:30:28 +0800 Subject: [PATCH 01/44] feat: extend issue template for yaml --- modules/context/repo.go | 91 +++++++++++++++++++-------------- modules/structs/issue.go | 105 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 44 deletions(-) diff --git a/modules/context/repo.go b/modules/context/repo.go index 5404acc05a3a6..06d78826d1919 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "path" + "path/filepath" "strings" "code.gitea.io/gitea/models" @@ -35,6 +36,7 @@ import ( asymkey_service "code.gitea.io/gitea/services/asymkey" "github.com/editorconfig/editorconfig-core-go/v2" + "gopkg.in/yaml.v2" ) // IssueTemplateDirCandidates issue templates directory @@ -1060,44 +1062,61 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { return issueTemplates } 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 t := ctx.extractIssueTemplate(entry); t != nil { + issueTemplates = append(issueTemplates, *t) } } - if len(issueTemplates) > 0 { - return issueTemplates - } } return issueTemplates } + +func (ctx *Context) extractIssueTemplate(entry *git.TreeEntry) *api.IssueTemplate { + it := &api.IssueTemplate{ + FileName: entry.Name(), + } + if it.Type() == "" { + return nil + } + + if name := filepath.Base(it.FileName); name == "config.yaml" || name == "config.yml" { + // ignore config.yaml which is a special configuration file + return nil + } + + if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize { + log.Debug("Issue template is too large: %s", entry.Name()) + return nil + } + + r, err := entry.Blob().DataAsync() + if err != nil { + log.Debug("DataAsync: %v", err) + return nil + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + log.Debug("ReadAll: %v", err) + return nil + } + + if it.Type() == "md" { + content, err := markdown.ExtractMetadata(string(data), it) + if err != nil { + log.Debug("ExtractMetadata: %v", err) + return nil + } + it.Content = content + } else if it.Type() == "yaml" { + if err := yaml.Unmarshal(data, it); err != nil { + log.Debug("Unmarshal: %v", err) + return nil + } + } + + if !it.Valid() { + return nil + } + return it +} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index c72487fe4dcad..68d54989890ad 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -5,6 +5,8 @@ package structs import ( + "fmt" + "path/filepath" "strings" "time" ) @@ -123,16 +125,103 @@ type IssueDeadline struct { // 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"` // TODO: compatible with description + Labels []string `json:"labels" yaml:"labels"` + Ref string `json:"ref" yaml:"ref"` + Content string `json:"content" yaml:"-"` // for markdown only + Body []*IssueTemplateField `json:"body" yaml:"body"` // for yaml only + FileName string `json:"file_name" yaml:"-"` +} + +type IssueTemplateField struct { + Type string `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"` +} + +// Validate checks whether an IssueTemplate is considered valid, and returns the first error +func (it IssueTemplate) Validate() error { + // TODO check the format of id, and more + + errMissField := func(f string) error { + return fmt.Errorf("field '%s' is required", f) + } + + if strings.TrimSpace(it.Name) != "" { + return errMissField("name") + } + if strings.TrimSpace(it.About) != "" { + return errMissField("about") + } + + for idx, field := range it.Body { + checkStringAttr := func(name string) error { + attr := field.Attributes[name] + if s, ok := attr.(string); !ok || s == "" { + return fmt.Errorf( + "body[%d]: the '%s' attribute is required and should be string with type %s", + idx, name, field.Type, + ) + } + return nil + } + switch field.Type { + case "markdown": + if err := checkStringAttr("value"); err != nil { + return err + } + case "textarea", "input", "dropdown": + if err := checkStringAttr("label"); err != nil { + return err + } + case "checkboxes": + if err := checkStringAttr("label"); err != nil { + } + if err := checkStringAttr("label"); err != nil { + return err + } + attr := field.Attributes["options"] + if options, ok := attr.([]map[string]any); !ok { + return fmt.Errorf( + "body[%d]: the '%s' attribute is required and should be array with type %s", + idx, "options", field.Type, + ) + } else { + for optIdx, option := range options { + label := option["label"] + if s, ok := label.(string); !ok || s == "" { + return fmt.Errorf( + "body[%d], option[%d]: the '%s' is required and should be string with type %s", + idx, optIdx, "label", field.Type, + ) + } + } + } + + default: + return fmt.Errorf( + "(field #%d '%s'): unknown type '%s'", + idx+1, field.ID, field.Type, + ) + } + } + return nil } // 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) != "" + return it.Validate() == nil +} + +// Type returns the type of IssueTemplate, it could be "md", "yaml" or empty for known +func (it IssueTemplate) Type() string { + if ext := filepath.Ext(it.FileName); ext == ".md" { + return "md" + } else if ext == ".yaml" || ext == ".yml" { + return "yaml" + } + return "" } From 4efe0b40fd0811dbbaa55e3350324aaf7bcc860b Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sat, 27 Aug 2022 21:26:34 +0800 Subject: [PATCH 02/44] feat: support yaml template --- modules/context/repo.go | 17 +---- modules/structs/issue.go | 103 +++++++++++++++++++++++--- routers/web/repo/issue.go | 72 +++++++++--------- templates/repo/issue/comment_tab.tmpl | 96 ++++++++++++++++++++---- 4 files changed, 215 insertions(+), 73 deletions(-) diff --git a/modules/context/repo.go b/modules/context/repo.go index 06d78826d1919..d8c3fad661e81 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -28,7 +28,6 @@ import ( "code.gitea.io/gitea/modules/git" code_indexer "code.gitea.io/gitea/modules/indexer/code" "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" @@ -36,7 +35,6 @@ import ( asymkey_service "code.gitea.io/gitea/services/asymkey" "github.com/editorconfig/editorconfig-core-go/v2" - "gopkg.in/yaml.v2" ) // IssueTemplateDirCandidates issue templates directory @@ -1101,18 +1099,9 @@ func (ctx *Context) extractIssueTemplate(entry *git.TreeEntry) *api.IssueTemplat return nil } - if it.Type() == "md" { - content, err := markdown.ExtractMetadata(string(data), it) - if err != nil { - log.Debug("ExtractMetadata: %v", err) - return nil - } - it.Content = content - } else if it.Type() == "yaml" { - if err := yaml.Unmarshal(data, it); err != nil { - log.Debug("Unmarshal: %v", err) - return nil - } + if err := it.Fill(data); err != nil { + log.Debug("fill template: %v", err) + return nil } if !it.Valid() { diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 68d54989890ad..70d6392c1467b 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + "gopkg.in/yaml.v2" ) // StateType issue state type @@ -127,7 +129,7 @@ type IssueDeadline struct { type IssueTemplate struct { Name string `json:"name" yaml:"name"` Title string `json:"title" yaml:"title"` - About string `json:"about" yaml:"about"` // TODO: compatible with description + About string `json:"about" yaml:"about"` Labels []string `json:"labels" yaml:"labels"` Ref string `json:"ref" yaml:"ref"` Content string `json:"content" yaml:"-"` // for markdown only @@ -142,18 +144,26 @@ type IssueTemplateField struct { Validations map[string]interface{} `json:"validations" yaml:"validations"` } +func NewIssueTemplate(filename string, content []byte) (*IssueTemplate, error) { + ret := &IssueTemplate{ + FileName: filename, + } + + return ret, nil +} + // Validate checks whether an IssueTemplate is considered valid, and returns the first error -func (it IssueTemplate) Validate() error { +func (it *IssueTemplate) Validate() error { // TODO check the format of id, and more errMissField := func(f string) error { return fmt.Errorf("field '%s' is required", f) } - if strings.TrimSpace(it.Name) != "" { + if strings.TrimSpace(it.Name) == "" { return errMissField("name") } - if strings.TrimSpace(it.About) != "" { + if strings.TrimSpace(it.About) == "" { return errMissField("about") } @@ -184,19 +194,26 @@ func (it IssueTemplate) Validate() error { return err } attr := field.Attributes["options"] - if options, ok := attr.([]map[string]any); !ok { + if options, ok := attr.([]any); !ok { return fmt.Errorf( "body[%d]: the '%s' attribute is required and should be array with type %s", idx, "options", field.Type, ) } else { for optIdx, option := range options { - label := option["label"] - if s, ok := label.(string); !ok || s == "" { + if opt, ok := option.(map[any]any); !ok { return fmt.Errorf( - "body[%d], option[%d]: the '%s' is required and should be string with type %s", - idx, optIdx, "label", field.Type, + "body[%d], option[%d]: should be dictionary with type %s", + idx, optIdx, field.Type, ) + } else { + label := opt["label"] + if s, ok := label.(string); !ok || s == "" { + return fmt.Errorf( + "body[%d], option[%d]: the '%s' is required and should be string with type %s", + idx, optIdx, "label", field.Type, + ) + } } } } @@ -212,12 +229,12 @@ func (it IssueTemplate) Validate() error { } // Valid checks whether an IssueTemplate is considered valid, e.g. at least name and about -func (it IssueTemplate) Valid() bool { +func (it *IssueTemplate) Valid() bool { return it.Validate() == nil } // Type returns the type of IssueTemplate, it could be "md", "yaml" or empty for known -func (it IssueTemplate) Type() string { +func (it *IssueTemplate) Type() string { if ext := filepath.Ext(it.FileName); ext == ".md" { return "md" } else if ext == ".yaml" || ext == ".yml" { @@ -225,3 +242,67 @@ func (it IssueTemplate) Type() string { } return "" } + +func (it *IssueTemplate) Fill(content []byte) error { + if typ := it.Type(); typ == "md" { + templateBody, err := it.extractMetadata(string(content), it) + if err != nil { + return fmt.Errorf("extract metadata: %w", err) + } + it.Content = templateBody + } else if typ == "yaml" { + if err := yaml.Unmarshal(content, it); err != nil { + return fmt.Errorf("yaml unmarshal: %w", err) + } + if it.About == "" { + // Compatible with treating description as about + compatibleTemplate := &struct { + About string `yaml:"description"` + }{} + if err := yaml.Unmarshal(content, compatibleTemplate); err == nil && compatibleTemplate.About != "" { + it.About = compatibleTemplate.About + } + } + } + return nil +} + +// extractMetadata consumes a markdown file, parses YAML frontmatter, +// and returns the frontmatter metadata separated from the markdown content. +// Copy from markdown.ExtractMetadata to avoid import cycle. +func (*IssueTemplate) extractMetadata(contents string, out interface{}) (string, error) { + isYAMLSeparator := func(line string) bool { + line = strings.TrimSpace(line) + for i := 0; i < len(line); i++ { + if line[i] != '-' { + return false + } + } + return len(line) > 2 + } + + var front, body []string + lines := strings.Split(contents, "\n") + for idx, line := range lines { + if idx == 0 { + // First line has to be a separator + if !isYAMLSeparator(line) { + return "", fmt.Errorf("frontmatter must start with a separator line") + } + continue + } + if isYAMLSeparator(line) { + front, body = lines[1:idx], lines[idx+1:] + break + } + } + + if len(front) == 0 { + return "", fmt.Errorf("could not determine metadata") + } + + if err := yaml.Unmarshal([]byte(strings.Join(front, "\n")), out); err != nil { + return "", err + } + return strings.Join(body, "\n"), nil +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3f14416e48371..fe53c665ed5dd 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -751,51 +751,57 @@ func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (str } func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) { - templateCandidates := make([]string, 0, len(possibleFiles)) - if ctx.FormString("template") != "" { + templateCandidates := make([]string, 0, len(possibleDirs)+len(possibleFiles)) + if t := ctx.FormString("template"); t != "" { for _, dirName := range possibleDirs { - templateCandidates = append(templateCandidates, path.Join(dirName, ctx.FormString("template"))) + templateCandidates = append(templateCandidates, path.Join(dirName, 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...) - } + if !found { + continue + } + template := api.IssueTemplate{ + FileName: filename, + } + if err := template.Fill([]byte(templateContent)); err != nil { + log.Debug("could fill template from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err) + ctx.Data[ctxDataKey] = templateContent + return + } + + ctx.Data[issueTemplateTitleKey] = template.Title + ctx.Data[ctxDataKey] = template.Content + if template.Type() == "yaml" { + ctx.Data[ctxDataKey+"Form"] = template // TODO use template.Body + } + 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 meta.Labels { - for _, repoLabel := range repoLabels { - if strings.EqualFold(repoLabel.Name, metaLabel) { - repoLabel.IsChecked = true - labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) - break - } + 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 } } diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index c1ca69dfb3e2f..59dccaf1ec046 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,19 +1,85 @@ - -
-
- +{{- if .IssueTemplateForm}} + + {{range $field_idx, $field := .IssueTemplateForm.Body}} + {{- if $field.Attributes.label}}

{{$field.Attributes.label}} {{if $field.Validations.required}} *{{end}}

{{end}} + {{- if $field.Attributes.description}}
{{RenderMarkdownToHtml $field.Attributes.description}}
{{end}} + + {{- if eq .Type "markdown"}} +
{{RenderMarkdownToHtml $field.Attributes.value}}
+ {{else if eq .Type "input"}} + + {{else if eq .Type "textarea"}} + {{- if .Attributes.render}} + + {{end}} + + {{else if eq .Type "checkboxes"}} + {{range $chk_id, $chk := $field.Attributes.options}} +
+ {{end}} + {{else if and (eq .Type "dropdown") $field.Attributes.multiple}} + {{range $chk_id, $chk := $field.Attributes.options}} +
+ {{end}} + {{else if eq .Type "dropdown"}} + + {{end}} + + {{end}} +{{else}} + -
- {{.locale.Tr "loading"}} -
-
-{{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} +
+ +
+
+ {{.locale.Tr "loading"}} +
+ {{if .IsAttachmentEnabled}} +
+ {{template "repo/upload" .}} +
+ {{end}} {{end}} From 1539e79d05cf8dc72fe71c3a07b1b880adadb550 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sat, 27 Aug 2022 21:51:30 +0800 Subject: [PATCH 03/44] feat: render form to markdown --- modules/structs/issue.go | 6 ++ routers/web/repo/issue.go | 83 ++++++++++++++++++++++++++- templates/repo/issue/comment_tab.tmpl | 2 +- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 70d6392c1467b..e1c99fdee9842 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -7,6 +7,7 @@ package structs import ( "fmt" "path/filepath" + "strconv" "strings" "time" @@ -263,6 +264,11 @@ func (it *IssueTemplate) Fill(content []byte) error { it.About = compatibleTemplate.About } } + for i, v := range it.Body { + if v.ID == "" { + v.ID = strconv.Itoa(i) + } + } } return nil } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index fe53c665ed5dd..9fc1cd1892475 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -775,7 +775,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, ctx.Data[issueTemplateTitleKey] = template.Title ctx.Data[ctxDataKey] = template.Content if template.Type() == "yaml" { - ctx.Data[ctxDataKey+"Form"] = template // TODO use template.Body + ctx.Data[ctxDataKey+"Form"] = template // TODO: maybe use template.Body } labelIDs := make([]string, 0, len(template.Labels)) if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { @@ -1037,6 +1037,19 @@ func NewIssuePost(ctx *context.Context) { return } + content := form.Content + if form := ctx.Req.Form; form.Has("template-file") { + // If the issue submitted is a form, render it to Markdown + if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { + ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") + ctx.Data["Flash"] = ctx.Flash + NewIssue(ctx) + return + } else { + content = c + } + } + issue := &issues_model.Issue{ RepoID: repo.ID, Repo: repo, @@ -1044,7 +1057,7 @@ func NewIssuePost(ctx *context.Context) { PosterID: ctx.Doer.ID, Poster: ctx.Doer, MilestoneID: milestoneID, - Content: form.Content, + Content: content, Ref: form.Ref, } @@ -1077,6 +1090,72 @@ func NewIssuePost(ctx *context.Context) { } } +// TODO: new design +// Renders the given form values to Markdown +// Returns an empty string if user submitted a non-form issue +func renderIssueFormValues(ctx *context.Context, form *url.Values) (string, error) { + filename := form.Get("template-file") + template := api.IssueTemplate{ + FileName: filename, + } + if templateContent, found := getFileContentFromDefaultBranch(ctx, form.Get("template-file")); !found { + return "", fmt.Errorf("template file %q not found", filename) + } else if err := template.Fill([]byte(templateContent)); err != nil { + return "", fmt.Errorf("fill template with %q: %w", filename, err) + } + + // Render values + result := "" + for _, field := range template.Body { + if field.ID != "" { + // Get field label + label := field.Attributes["label"] + if label == "" { + label = field.ID + } + + // Format the value into Markdown + if field.Type == "markdown" { + // Markdown blocks do not appear in output + } else if field.Type == "checkboxes" || (field.Type == "dropdown" && field.Attributes["multiple"] == true) { + result += fmt.Sprintf("### %s\n", label) + for i, option := range field.Attributes["options"].([]interface{}) { + // Get "checked" value + checkedStr := " " + isChecked := form.Get(fmt.Sprintf("form-field-%s-%d", field.ID, i)) == "on" + if isChecked { + checkedStr = "x" + } else if field.Type == "checkboxes" && (option.(map[interface{}]interface{})["required"] == true && !isChecked) { + return "", fmt.Errorf("checkbox #%d in field '%s' is required, but not checked", i, field.ID) + } + + // Get label + var label string + if field.Type == "checkboxes" { + label = option.(map[interface{}]interface{})["label"].(string) + } else { + label = option.(string) + } + result += fmt.Sprintf("- [%s] %s\n", checkedStr, label) + } + result += "\n" + } else if field.Type == "input" || field.Type == "textarea" || field.Type == "dropdown" { + if renderType, ok := field.Attributes["render"]; ok { + result += fmt.Sprintf("### %s\n```%s\n%s\n```\n\n", label, renderType, form.Get("form-field-"+field.ID)) + } else { + result += fmt.Sprintf("### %s\n%s\n\n", label, form.Get("form-field-"+field.ID)) + } + } else { + // Template should have been validated at this point + // TODO: should be better implemented + panic(fmt.Errorf("Invalid field type: '%s'", field.Type)) + } + } + } + + return result, nil +} + // roleDescriptor returns the Role Descriptor for a comment in/with the given repo, poster and issue func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue) (issues_model.RoleDescriptor, error) { perm, err := access_model.GetUserRepoPermission(ctx, repo, poster) diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 59dccaf1ec046..684137dffe4c6 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,5 +1,5 @@ {{- if .IssueTemplateForm}} - + {{range $field_idx, $field := .IssueTemplateForm.Body}} {{- if $field.Attributes.label}}

{{$field.Attributes.label}} {{if $field.Validations.required}} *{{end}}

{{end}} {{- if $field.Attributes.description}}
{{RenderMarkdownToHtml $field.Attributes.description}}
{{end}} From bf89e5ae0c9aea2c4e0235976255999cf2df9691 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sat, 27 Aug 2022 22:16:12 +0800 Subject: [PATCH 04/44] feat: support yaml template for pr --- modules/structs/issue.go | 8 ------ routers/web/repo/issue.go | 14 ++++++++- routers/web/repo/pull.go | 41 +++++++++++++++++++++++++-- templates/repo/issue/comment_tab.tmpl | 6 ++-- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index e1c99fdee9842..6f3dc2b1f34bc 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -145,14 +145,6 @@ type IssueTemplateField struct { Validations map[string]interface{} `json:"validations" yaml:"validations"` } -func NewIssueTemplate(filename string, content []byte) (*IssueTemplate, error) { - ret := &IssueTemplate{ - FileName: filename, - } - - return ret, nil -} - // Validate checks whether an IssueTemplate is considered valid, and returns the first error func (it *IssueTemplate) Validate() error { // TODO check the format of id, and more diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 9fc1cd1892475..290a7ece441a7 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -70,11 +70,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.yml", ".github/ISSUE_TEMPLATE.md", ".github/issue_template.md", + ".github/issue_template.yaml", + ".github/issue_template.yml", } // MustAllowUserComment checks to make sure if an issue is locked. @@ -775,7 +787,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, ctx.Data[issueTemplateTitleKey] = template.Title ctx.Data[ctxDataKey] = template.Content if template.Type() == "yaml" { - ctx.Data[ctxDataKey+"Form"] = template // TODO: maybe use template.Body + ctx.Data["TemplateForm"] = template // TODO: maybe use template.Body } labelIDs := make([]string, 0, len(template.Labels)) if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 9b2c7c02cb78b..4342660f0a015 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -58,11 +58,20 @@ 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.md", + ".github/PULL_REQUEST_TEMPLATE.yaml", + ".github/PULL_REQUEST_TEMPLATE.yml", } func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { @@ -1171,11 +1180,24 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } + content := form.Content + if form := ctx.Req.Form; form.Has("template-file") { + // If the issue submitted is a form, render it to Markdown + if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { + ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") + ctx.Data["Flash"] = ctx.Flash + NewIssue(ctx) + return + } else { + content = c + } + } + if len(form.Title) > 255 { var trailer string form.Title, trailer = util.SplitStringAtByteN(form.Title, 255) - form.Content = trailer + "\n\n" + form.Content + content = trailer + "\n\n" + content } middleware.AssignForm(form, ctx.Data) @@ -1194,6 +1216,19 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } + content := form.Content + if form := ctx.Req.Form; form.Has("template-file") { + // If the issue submitted is a form, render it to Markdown + if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { + ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") + ctx.Data["Flash"] = ctx.Flash + NewIssue(ctx) + return + } else { + content = c + } + } + pullIssue := &issues_model.Issue{ RepoID: repo.ID, Repo: repo, @@ -1202,7 +1237,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { Poster: ctx.Doer, MilestoneID: milestoneID, IsPull: true, - Content: form.Content, + Content: content, } pullRequest := &issues_model.PullRequest{ HeadRepoID: ci.HeadRepo.ID, diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 684137dffe4c6..9a471ccc6621c 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,6 +1,6 @@ -{{- if .IssueTemplateForm}} - - {{range $field_idx, $field := .IssueTemplateForm.Body}} +{{- if .TemplateForm}} + + {{range $field_idx, $field := .TemplateForm.Body}} {{- if $field.Attributes.label}}

{{$field.Attributes.label}} {{if $field.Validations.required}} *{{end}}

{{end}} {{- if $field.Attributes.description}}
{{RenderMarkdownToHtml $field.Attributes.description}}
{{end}} From 3337a6c7c348232ae865cbd14adabe4a8e496fbd Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 10:31:00 +0800 Subject: [PATCH 05/44] chore: rename to Fields --- modules/issue/template/template.go | 24 ++++++++++++++++++++++ modules/structs/issue.go | 33 +++++++++++++++--------------- routers/web/repo/issue.go | 2 +- 3 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 modules/issue/template/template.go diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go new file mode 100644 index 0000000000000..b9ec2ae6921b9 --- /dev/null +++ b/modules/issue/template/template.go @@ -0,0 +1,24 @@ +// 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 "html/template" + +// Field represents a field interface which could be a component in the issue create UI +type Field interface { + Name() string + Description() string + Render() template.HTML +} + +type CheckBox struct{} + +type Markdown struct{} + +type Input struct{} + +type TextArea struct{} + +type Dropdown struct{} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 6f3dc2b1f34bc..2988cd0465135 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -125,26 +125,27 @@ type IssueDeadline struct { Deadline *time.Time `json:"due_date"` } -// 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:"-"` // for markdown only - Body []*IssueTemplateField `json:"body" yaml:"body"` // for yaml only - FileName string `json:"file_name" yaml:"-"` -} - -type IssueTemplateField struct { +// IssueFormField represents a form field +type IssueFormField struct { Type string `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:"-"` // for markdown only + Fields []*IssueFormField `json:"body" yaml:"body"` // for yaml only + FileName string `json:"file_name" yaml:"-"` +} + // Validate checks whether an IssueTemplate is considered valid, and returns the first error func (it *IssueTemplate) Validate() error { // TODO check the format of id, and more @@ -160,7 +161,7 @@ func (it *IssueTemplate) Validate() error { return errMissField("about") } - for idx, field := range it.Body { + for idx, field := range it.Fields { checkStringAttr := func(name string) error { attr := field.Attributes[name] if s, ok := attr.(string); !ok || s == "" { @@ -256,7 +257,7 @@ func (it *IssueTemplate) Fill(content []byte) error { it.About = compatibleTemplate.About } } - for i, v := range it.Body { + for i, v := range it.Fields { if v.ID == "" { v.ID = strconv.Itoa(i) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 290a7ece441a7..a2acb1b3d7b2a 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1118,7 +1118,7 @@ func renderIssueFormValues(ctx *context.Context, form *url.Values) (string, erro // Render values result := "" - for _, field := range template.Body { + for _, field := range template.Fields { if field.ID != "" { // Get field label label := field.Attributes["label"] From d25217d9b65f543a96fd3c850924e507ea9ddccc Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 11:44:21 +0800 Subject: [PATCH 06/44] feat: template unmarshal --- modules/context/repo.go | 62 +++++-------------- modules/issue/template/unmarshal.go | 92 +++++++++++++++++++++++++++++ modules/structs/issue.go | 77 ++---------------------- routers/web/repo/issue.go | 58 ++++-------------- 4 files changed, 123 insertions(+), 166 deletions(-) create mode 100644 modules/issue/template/unmarshal.go diff --git a/modules/context/repo.go b/modules/context/repo.go index d8c3fad661e81..3d19db31eeb38 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -9,11 +9,9 @@ import ( "context" "fmt" "html" - "io" "net/http" "net/url" "path" - "path/filepath" "strings" "code.gitea.io/gitea/models" @@ -27,6 +25,7 @@ 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" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" @@ -1035,8 +1034,8 @@ 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 +func (ctx *Context) IssueTemplatesFromDefaultBranch() []*api.IssueTemplate { + var issueTemplates []*api.IssueTemplate if ctx.Repo.Repository.IsEmpty { return issueTemplates @@ -1060,52 +1059,19 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { return issueTemplates } for _, entry := range entries { - if t := ctx.extractIssueTemplate(entry); t != nil { - issueTemplates = append(issueTemplates, *t) + it := &api.IssueTemplate{ + FileName: entry.Name(), + } + if it.Type() == "" { + continue + } + it, err := template.UnmarshalFromEntry(entry) + if err != nil { + log.Debug("unmarshal template from %s: %v", entry.Name(), err) + } else if it.Valid() { + issueTemplates = append(issueTemplates, it) } } } return issueTemplates } - -func (ctx *Context) extractIssueTemplate(entry *git.TreeEntry) *api.IssueTemplate { - it := &api.IssueTemplate{ - FileName: entry.Name(), - } - if it.Type() == "" { - return nil - } - - if name := filepath.Base(it.FileName); name == "config.yaml" || name == "config.yml" { - // ignore config.yaml which is a special configuration file - return nil - } - - if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize { - log.Debug("Issue template is too large: %s", entry.Name()) - return nil - } - - r, err := entry.Blob().DataAsync() - if err != nil { - log.Debug("DataAsync: %v", err) - return nil - } - defer r.Close() - - data, err := io.ReadAll(r) - if err != nil { - log.Debug("ReadAll: %v", err) - return nil - } - - if err := it.Fill(data); err != nil { - log.Debug("fill template: %v", err) - return nil - } - - if !it.Valid() { - return nil - } - return it -} diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go new file mode 100644 index 0000000000000..97c5f2a211ef7 --- /dev/null +++ b/modules/issue/template/unmarshal.go @@ -0,0 +1,92 @@ +// 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" + "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" +) + +// TODO +func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) { + it := &api.IssueTemplate{ + FileName: filename, + } + + if typ := it.Type(); typ == "md" { + templateBody, err := markdown.ExtractMetadata(string(content), it) + if err != nil { + return nil, fmt.Errorf("extract metadata: %w", err) + } + it.Content = templateBody + } else if typ == "yaml" { + if err := yaml.Unmarshal(content, it); err != nil { + return nil, fmt.Errorf("yaml unmarshal: %w", err) + } + if it.About == "" { + // Compatible with treating description as about + compatibleTemplate := &struct { + About string `yaml:"description"` + }{} + 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 +} + +// TODO +func UnmarshalFromEntry(entry *git.TreeEntry) (*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(entry.Name(), content) +} + +// TODO +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) +} + +// TODO +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) +} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 2988cd0465135..73a9d246faeb9 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -7,11 +7,8 @@ package structs import ( "fmt" "path/filepath" - "strconv" "strings" "time" - - "gopkg.in/yaml.v2" ) // StateType issue state type @@ -229,6 +226,11 @@ func (it *IssueTemplate) Valid() bool { // Type returns the type of IssueTemplate, it could be "md", "yaml" or empty for known func (it *IssueTemplate) Type() string { + if it.Name == "config.yaml" || it.Name == "config.yml" { + // TODO: should it be? + // ignore config.yaml which is a special configuration file + return "" + } if ext := filepath.Ext(it.FileName); ext == ".md" { return "md" } else if ext == ".yaml" || ext == ".yml" { @@ -236,72 +238,3 @@ func (it *IssueTemplate) Type() string { } return "" } - -func (it *IssueTemplate) Fill(content []byte) error { - if typ := it.Type(); typ == "md" { - templateBody, err := it.extractMetadata(string(content), it) - if err != nil { - return fmt.Errorf("extract metadata: %w", err) - } - it.Content = templateBody - } else if typ == "yaml" { - if err := yaml.Unmarshal(content, it); err != nil { - return fmt.Errorf("yaml unmarshal: %w", err) - } - if it.About == "" { - // Compatible with treating description as about - compatibleTemplate := &struct { - About string `yaml:"description"` - }{} - 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 nil -} - -// extractMetadata consumes a markdown file, parses YAML frontmatter, -// and returns the frontmatter metadata separated from the markdown content. -// Copy from markdown.ExtractMetadata to avoid import cycle. -func (*IssueTemplate) extractMetadata(contents string, out interface{}) (string, error) { - isYAMLSeparator := func(line string) bool { - line = strings.TrimSpace(line) - for i := 0; i < len(line); i++ { - if line[i] != '-' { - return false - } - } - return len(line) > 2 - } - - var front, body []string - lines := strings.Split(contents, "\n") - for idx, line := range lines { - if idx == 0 { - // First line has to be a separator - if !isYAMLSeparator(line) { - return "", fmt.Errorf("frontmatter must start with a separator line") - } - continue - } - if isYAMLSeparator(line) { - front, body = lines[1:idx], lines[idx+1:] - break - } - } - - if len(front) == 0 { - return "", fmt.Errorf("could not determine metadata") - } - - if err := yaml.Unmarshal([]byte(strings.Join(front, "\n")), out); err != nil { - return "", err - } - return strings.Join(body, "\n"), nil -} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a2acb1b3d7b2a..aa4545c126b7c 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -10,7 +10,6 @@ import ( stdCtx "context" "errors" "fmt" - "io" "math/big" "net/http" "net/url" @@ -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" @@ -734,34 +734,6 @@ 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 - } - } - - 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(possibleDirs)+len(possibleFiles)) if t := ctx.FormString("template"); t != "" { @@ -771,23 +743,19 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, } 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 { + template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename) + if err == nil { continue } - template := api.IssueTemplate{ - FileName: filename, - } - if err := template.Fill([]byte(templateContent)); err != nil { - log.Debug("could fill template from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err) - ctx.Data[ctxDataKey] = templateContent - return + if !template.Valid() { + continue } ctx.Data[issueTemplateTitleKey] = template.Title ctx.Data[ctxDataKey] = template.Content + if template.Type() == "yaml" { - ctx.Data["TemplateForm"] = template // TODO: maybe use template.Body + ctx.Data["Fields"] = template.Fields } labelIDs := make([]string, 0, len(template.Labels)) if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil { @@ -1107,13 +1075,11 @@ func NewIssuePost(ctx *context.Context) { // Returns an empty string if user submitted a non-form issue func renderIssueFormValues(ctx *context.Context, form *url.Values) (string, error) { filename := form.Get("template-file") - template := api.IssueTemplate{ - FileName: filename, - } - if templateContent, found := getFileContentFromDefaultBranch(ctx, form.Get("template-file")); !found { - return "", fmt.Errorf("template file %q not found", filename) - } else if err := template.Fill([]byte(templateContent)); err != nil { - return "", fmt.Errorf("fill template with %q: %w", filename, err) + template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, form.Get("template-file")) + if err != nil { + return "", fmt.Errorf("unmarshal template %q: %w", filename, err) + } else if !template.Valid() { + return "", fmt.Errorf("invalid template %q", filename) } // Render values From c0a37272fa2deb64daaf793d569c6fa60d90863e Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 15:15:39 +0800 Subject: [PATCH 07/44] feat: split template --- modules/issue/template/template.go | 1 + modules/structs/issue.go | 10 +-- routers/web/repo/issue.go | 2 +- templates/repo/issue/comment_tab.tmpl | 84 +++++---------------- templates/repo/issue/fields/checkboxes.tmpl | 13 ++++ templates/repo/issue/fields/dropdown.tmpl | 23 ++++++ templates/repo/issue/fields/header.tmpl | 10 +++ templates/repo/issue/fields/input.tmpl | 9 +++ templates/repo/issue/fields/markdown.tmpl | 1 + templates/repo/issue/fields/textarea.tmpl | 8 ++ 10 files changed, 88 insertions(+), 73 deletions(-) create mode 100644 templates/repo/issue/fields/checkboxes.tmpl create mode 100644 templates/repo/issue/fields/dropdown.tmpl create mode 100644 templates/repo/issue/fields/header.tmpl create mode 100644 templates/repo/issue/fields/input.tmpl create mode 100644 templates/repo/issue/fields/markdown.tmpl create mode 100644 templates/repo/issue/fields/textarea.tmpl diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go index b9ec2ae6921b9..7e1b34976ef61 100644 --- a/modules/issue/template/template.go +++ b/modules/issue/template/template.go @@ -7,6 +7,7 @@ package template import "html/template" // Field represents a field interface which could be a component in the issue create UI +// TODO: Do we need it? type Field interface { Name() string Description() string diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 73a9d246faeb9..420ca872dc35b 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -138,13 +138,13 @@ type IssueTemplate struct { About string `json:"about" yaml:"about"` Labels []string `json:"labels" yaml:"labels"` Ref string `json:"ref" yaml:"ref"` - Content string `json:"content" yaml:"-"` // for markdown only - Fields []*IssueFormField `json:"body" yaml:"body"` // for yaml only + Content string `json:"content" yaml:"-"` + Fields []*IssueFormField `json:"body" yaml:"body"` FileName string `json:"file_name" yaml:"-"` } // Validate checks whether an IssueTemplate is considered valid, and returns the first error -func (it *IssueTemplate) Validate() error { +func (it IssueTemplate) Validate() error { // TODO check the format of id, and more errMissField := func(f string) error { @@ -220,12 +220,12 @@ func (it *IssueTemplate) Validate() error { } // Valid checks whether an IssueTemplate is considered valid, e.g. at least name and about -func (it *IssueTemplate) Valid() bool { +func (it IssueTemplate) Valid() bool { return it.Validate() == nil } // Type returns the type of IssueTemplate, it could be "md", "yaml" or empty for known -func (it *IssueTemplate) Type() string { +func (it IssueTemplate) Type() string { if it.Name == "config.yaml" || it.Name == "config.yml" { // TODO: should it be? // ignore config.yaml which is a special configuration file diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index aa4545c126b7c..4ef16d39c11ef 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -744,7 +744,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback for _, filename := range templateCandidates { template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename) - if err == nil { + if err != nil { continue } if !template.Valid() { diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 9a471ccc6621c..48c01bb2a362c 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,66 +1,16 @@ -{{- if .TemplateForm}} - - {{range $field_idx, $field := .TemplateForm.Body}} - {{- if $field.Attributes.label}}

{{$field.Attributes.label}} {{if $field.Validations.required}} *{{end}}

{{end}} - {{- if $field.Attributes.description}}
{{RenderMarkdownToHtml $field.Attributes.description}}
{{end}} - - {{- if eq .Type "markdown"}} -
{{RenderMarkdownToHtml $field.Attributes.value}}
- {{else if eq .Type "input"}} - +{{if .Fields}} + {{range .Fields}} + {{if eq .Type "input"}} + {{template "repo/issue/fields/input" .}} + {{else if eq .Type "markdown"}} + {{template "repo/issue/fields/markdown" .}} {{else if eq .Type "textarea"}} - {{- if .Attributes.render}} - - {{end}} - - {{else if eq .Type "checkboxes"}} - {{range $chk_id, $chk := $field.Attributes.options}} -
- {{end}} - {{else if and (eq .Type "dropdown") $field.Attributes.multiple}} - {{range $chk_id, $chk := $field.Attributes.options}} -
- {{end}} + {{template "repo/issue/fields/textarea" .}} {{else if eq .Type "dropdown"}} - + {{template "repo/issue/fields/dropdown" .}} + {{else if eq .Type "checkboxes"}} + {{template "repo/issue/fields/checkboxes" .}} {{end}} - {{end}} {{else}}
- +
- {{.locale.Tr "loading"}} + {{.locale.Tr "loading"}}
- {{if .IsAttachmentEnabled}} + {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" .}}
- {{end}} + {{end}} {{end}} diff --git a/templates/repo/issue/fields/checkboxes.tmpl b/templates/repo/issue/fields/checkboxes.tmpl new file mode 100644 index 0000000000000..19ea63f15ef16 --- /dev/null +++ b/templates/repo/issue/fields/checkboxes.tmpl @@ -0,0 +1,13 @@ +{{template "repo/issue/fields/header" .}} +{{$field := .}} +{{range $i, $opt := .Attributes.options}} +
+{{end}} diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl new file mode 100644 index 0000000000000..f2942252f1401 --- /dev/null +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -0,0 +1,23 @@ +{{template "repo/issue/fields/header" .}} +{{$field := .}} +{{if .Attributes.multiple}} + {{range $i, $opt := .Attributes.options}} +
+ {{end}} +{{else}} + +{{end}} diff --git a/templates/repo/issue/fields/header.tmpl b/templates/repo/issue/fields/header.tmpl new file mode 100644 index 0000000000000..991eef8f8b616 --- /dev/null +++ b/templates/repo/issue/fields/header.tmpl @@ -0,0 +1,10 @@ +{{- if .Attributes.label}} +

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

+{{end}} +{{- if .Attributes.description}} +
{{RenderMarkdownToHtml .Attributes.description}}
+{{end}} diff --git a/templates/repo/issue/fields/input.tmpl b/templates/repo/issue/fields/input.tmpl new file mode 100644 index 0000000000000..75e748b30052a --- /dev/null +++ b/templates/repo/issue/fields/input.tmpl @@ -0,0 +1,9 @@ +{{template "repo/issue/fields/header" .}} + diff --git a/templates/repo/issue/fields/markdown.tmpl b/templates/repo/issue/fields/markdown.tmpl new file mode 100644 index 0000000000000..668ad3e93da20 --- /dev/null +++ b/templates/repo/issue/fields/markdown.tmpl @@ -0,0 +1 @@ +
{{RenderMarkdownToHtml .Attributes.value}}
diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl new file mode 100644 index 0000000000000..260ddaf998f7c --- /dev/null +++ b/templates/repo/issue/fields/textarea.tmpl @@ -0,0 +1,8 @@ +{{template "repo/issue/fields/header" .}} + From 7de200b7de665df45b414397d8998860dcffe270 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 16:41:03 +0800 Subject: [PATCH 08/44] feat: render to markdown --- modules/issue/template/render.go | 121 +++++++++++++++++++++++++++++++ routers/web/repo/issue.go | 76 +------------------ routers/web/repo/pull.go | 31 ++------ 3 files changed, 132 insertions(+), 96 deletions(-) create mode 100644 modules/issue/template/render.go diff --git a/modules/issue/template/render.go b/modules/issue/template/render.go new file mode 100644 index 0000000000000..189b714b5cf9f --- /dev/null +++ b/modules/issue/template/render.go @@ -0,0 +1,121 @@ +// 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" + "strings" + + api "code.gitea.io/gitea/modules/structs" +) + +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 == "markdown" { + // markdown blocks do not appear in output + return + } + + // write label + _, _ = fmt.Fprintf(builder, "### %s\n", f.Label()) + + // write body + switch f.Type { + case "checkboxes", "dropdown": + for _, option := range f.Options() { + checked := " " + if option.IsChecked() { + checked = "x" + } + _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label()) + } + case "input": + _, _ = fmt.Fprintf(builder, "%s\n", f.Value()) + case "textarea": + _, _ = fmt.Fprintf(builder, "```%s\n%s```", f.Render(), f.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 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 "dropdown": + if label, ok := o.data.(string); ok { + return label + } + case "checkboxes": + if vs, ok := o.data.(map[interface{}]interface{}); ok { + if v, ok := vs["label"].(string); ok { + return v + } + } + } + return "" +} + +func (o *valuedOption) IsChecked() bool { + return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on" +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 4ef16d39c11ef..f2c81c87c6a46 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1018,15 +1018,9 @@ func NewIssuePost(ctx *context.Context) { } content := form.Content - if form := ctx.Req.Form; form.Has("template-file") { - // If the issue submitted is a form, render it to Markdown - if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { - ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") - ctx.Data["Flash"] = ctx.Flash - NewIssue(ctx) - return - } else { - content = c + 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) } } @@ -1070,70 +1064,6 @@ func NewIssuePost(ctx *context.Context) { } } -// TODO: new design -// Renders the given form values to Markdown -// Returns an empty string if user submitted a non-form issue -func renderIssueFormValues(ctx *context.Context, form *url.Values) (string, error) { - filename := form.Get("template-file") - template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, form.Get("template-file")) - if err != nil { - return "", fmt.Errorf("unmarshal template %q: %w", filename, err) - } else if !template.Valid() { - return "", fmt.Errorf("invalid template %q", filename) - } - - // Render values - result := "" - for _, field := range template.Fields { - if field.ID != "" { - // Get field label - label := field.Attributes["label"] - if label == "" { - label = field.ID - } - - // Format the value into Markdown - if field.Type == "markdown" { - // Markdown blocks do not appear in output - } else if field.Type == "checkboxes" || (field.Type == "dropdown" && field.Attributes["multiple"] == true) { - result += fmt.Sprintf("### %s\n", label) - for i, option := range field.Attributes["options"].([]interface{}) { - // Get "checked" value - checkedStr := " " - isChecked := form.Get(fmt.Sprintf("form-field-%s-%d", field.ID, i)) == "on" - if isChecked { - checkedStr = "x" - } else if field.Type == "checkboxes" && (option.(map[interface{}]interface{})["required"] == true && !isChecked) { - return "", fmt.Errorf("checkbox #%d in field '%s' is required, but not checked", i, field.ID) - } - - // Get label - var label string - if field.Type == "checkboxes" { - label = option.(map[interface{}]interface{})["label"].(string) - } else { - label = option.(string) - } - result += fmt.Sprintf("- [%s] %s\n", checkedStr, label) - } - result += "\n" - } else if field.Type == "input" || field.Type == "textarea" || field.Type == "dropdown" { - if renderType, ok := field.Attributes["render"]; ok { - result += fmt.Sprintf("### %s\n```%s\n%s\n```\n\n", label, renderType, form.Get("form-field-"+field.ID)) - } else { - result += fmt.Sprintf("### %s\n%s\n\n", label, form.Get("form-field-"+field.ID)) - } - } else { - // Template should have been validated at this point - // TODO: should be better implemented - panic(fmt.Errorf("Invalid field type: '%s'", field.Type)) - } - } - } - - return result, nil -} - // roleDescriptor returns the Role Descriptor for a comment in/with the given repo, poster and issue func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue) (issues_model.RoleDescriptor, error) { perm, err := access_model.GetUserRepoPermission(ctx, repo, poster) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 4342660f0a015..aa2c4cdb53b2d 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -30,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" @@ -72,6 +73,9 @@ var pullRequestTemplateCandidates = []string{ ".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 { @@ -1180,24 +1184,11 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - content := form.Content - if form := ctx.Req.Form; form.Has("template-file") { - // If the issue submitted is a form, render it to Markdown - if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { - ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") - ctx.Data["Flash"] = ctx.Flash - NewIssue(ctx) - return - } else { - content = c - } - } - if len(form.Title) > 255 { var trailer string form.Title, trailer = util.SplitStringAtByteN(form.Title, 255) - content = trailer + "\n\n" + content + form.Content = trailer + "\n\n" + form.Content } middleware.AssignForm(form, ctx.Data) @@ -1217,15 +1208,9 @@ func CompareAndPullRequestPost(ctx *context.Context) { } content := form.Content - if form := ctx.Req.Form; form.Has("template-file") { - // If the issue submitted is a form, render it to Markdown - if c, err := renderIssueFormValues(ctx, &ctx.Req.Form); err != nil { - ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") - ctx.Data["Flash"] = ctx.Flash - NewIssue(ctx) - return - } else { - content = c + 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) } } From a949489e41871ae7199799132d4935da66effe5b Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 18:01:34 +0800 Subject: [PATCH 09/44] feat: use full name as template file name --- modules/context/repo.go | 7 ++-- modules/issue/template/render.go | 2 +- modules/issue/template/unmarshal.go | 48 +++++++++++++++++---------- routers/web/repo/compare.go | 2 +- routers/web/repo/issue.go | 12 +++---- templates/repo/issue/comment_tab.tmpl | 1 + 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/modules/context/repo.go b/modules/context/repo.go index 3d19db31eeb38..272811442feae 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -1059,13 +1059,10 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []*api.IssueTemplate { return issueTemplates } for _, entry := range entries { - it := &api.IssueTemplate{ - FileName: entry.Name(), - } - if it.Type() == "" { + if !template.CouldBe(entry.Name()) { continue } - it, err := template.UnmarshalFromEntry(entry) + it, err := template.UnmarshalFromEntry(entry, dirName) if err != nil { log.Debug("unmarshal template from %s: %v", entry.Name(), err) } else if it.Valid() { diff --git a/modules/issue/template/render.go b/modules/issue/template/render.go index 189b714b5cf9f..c4e8e899e922b 100644 --- a/modules/issue/template/render.go +++ b/modules/issue/template/render.go @@ -56,7 +56,7 @@ func (f *valuedField) WriteTo(builder *strings.Builder) { case "input": _, _ = fmt.Fprintf(builder, "%s\n", f.Value()) case "textarea": - _, _ = fmt.Fprintf(builder, "```%s\n%s```", f.Render(), f.Value()) + _, _ = fmt.Fprintf(builder, "```%s\n%s\n```\n", f.Render(), f.Value()) } _, _ = fmt.Fprintln(builder) } diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 97c5f2a211ef7..12b2be18d2fc9 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -7,6 +7,7 @@ package template import ( "fmt" "io" + "path/filepath" "strconv" "code.gitea.io/gitea/modules/git" @@ -53,23 +54,8 @@ func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) { } // TODO -func UnmarshalFromEntry(entry *git.TreeEntry) (*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(entry.Name(), content) +func UnmarshalFromEntry(entry *git.TreeEntry, dir string) (*api.IssueTemplate, error) { + return unmarshalFromEntry(entry, filepath.Join(dir, entry.Name())) } // TODO @@ -78,7 +64,7 @@ func UnmarshalFromCommit(commit *git.Commit, filename string) (*api.IssueTemplat if err != nil { return nil, fmt.Errorf("get entry for %q: %w", filename, err) } - return UnmarshalFromEntry(entry) + return unmarshalFromEntry(entry, filename) } // TODO @@ -90,3 +76,29 @@ func UnmarshalFromRepo(repo *git.Repository, branch, filename string) (*api.Issu 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 CouldBe(filename string) bool { + it := &api.IssueTemplate{ + FileName: filename, + } + return it.Type() != "" +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index f34d3a520343a..cdd80f57a7b58 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -784,7 +784,7 @@ func CompareDiff(ctx *context.Context) { ctx.Data["IsRepoToolbarCommits"] = true ctx.Data["IsDiffCompare"] = true ctx.Data["RequireTribute"] = true - setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates) + setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) // If a template content is set, prepend the "content". In this case that's only // applicable if you have one commit to compare and that commit has a message. diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index f2c81c87c6a46..4d6794f4d1ec9 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -13,7 +13,6 @@ import ( "math/big" "net/http" "net/url" - "path" "strconv" "strings" "time" @@ -734,12 +733,10 @@ func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull return labels } -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) { - templateCandidates := make([]string, 0, len(possibleDirs)+len(possibleFiles)) +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) { + templateCandidates := make([]string, 0, 1+len(possibleFiles)) if t := ctx.FormString("template"); t != "" { - for _, dirName := range possibleDirs { - templateCandidates = append(templateCandidates, path.Join(dirName, t)) - } + templateCandidates = append(templateCandidates, t) } templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback for _, filename := range templateCandidates { @@ -756,6 +753,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, if template.Type() == "yaml" { 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 { @@ -831,7 +829,7 @@ func NewIssue(ctx *context.Context) { } RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) - setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates) + setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) if ctx.Written() { return } diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 48c01bb2a362c..f7acd4da76551 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,4 +1,5 @@ {{if .Fields}} + {{range .Fields}} {{if eq .Type "input"}} {{template "repo/issue/fields/input" .}} From 64190cdc9481a7a2ad0fbd50e7773be6393ce43b Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 18:23:58 +0800 Subject: [PATCH 10/44] chore: remove useless file --- modules/issue/template/template.go | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 modules/issue/template/template.go diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go deleted file mode 100644 index 7e1b34976ef61..0000000000000 --- a/modules/issue/template/template.go +++ /dev/null @@ -1,25 +0,0 @@ -// 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 "html/template" - -// Field represents a field interface which could be a component in the issue create UI -// TODO: Do we need it? -type Field interface { - Name() string - Description() string - Render() template.HTML -} - -type CheckBox struct{} - -type Markdown struct{} - -type Input struct{} - -type TextArea struct{} - -type Dropdown struct{} From ad7e58cf7fc47fae13aa1de03a81bf3ba1c81d7a Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 21:07:17 +0800 Subject: [PATCH 11/44] feat: use dropdown of fomantic ui --- modules/issue/template/render.go | 42 ++++++++++++++++++++--- templates/repo/issue/fields/dropdown.tmpl | 33 ++++++------------ 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/modules/issue/template/render.go b/modules/issue/template/render.go index c4e8e899e922b..4ba7c978fafd4 100644 --- a/modules/issue/template/render.go +++ b/modules/issue/template/render.go @@ -7,6 +7,7 @@ package template import ( "fmt" "net/url" + "strconv" "strings" api "code.gitea.io/gitea/modules/structs" @@ -43,20 +44,46 @@ func (f *valuedField) WriteTo(builder *strings.Builder) { // write label _, _ = fmt.Fprintf(builder, "### %s\n", f.Label()) + blankPlaceholder := "_No response_\n" + // write body switch f.Type { - case "checkboxes", "dropdown": + case "checkboxes": + empty := true for _, option := range f.Options() { checked := " " if option.IsChecked() { checked = "x" + empty = true } _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label()) } + if empty { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } + case "dropdown": + 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 "input": + if v := f.Value(); v == "" { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } _, _ = fmt.Fprintf(builder, "%s\n", f.Value()) case "textarea": - _, _ = fmt.Fprintf(builder, "```%s\n%s\n```\n", f.Render(), f.Value()) + if v := f.Value(); v == "" { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } else { + _, _ = fmt.Fprintf(builder, "```%s\n%s\n```\n", f.Render(), f.Value()) + } } _, _ = fmt.Fprintln(builder) } @@ -76,7 +103,7 @@ func (f *valuedField) Render() string { } func (f *valuedField) Value() string { - return f.Get(fmt.Sprintf("form-field-" + f.ID)) + return strings.TrimSpace(f.Get(fmt.Sprintf("form-field-" + f.ID))) } func (f *valuedField) Options() []*valuedOption { @@ -117,5 +144,12 @@ func (o *valuedOption) Label() string { } func (o *valuedOption) IsChecked() bool { - return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on" + 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 } diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl index f2942252f1401..cb9db39830d0e 100644 --- a/templates/repo/issue/fields/dropdown.tmpl +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -1,23 +1,12 @@ {{template "repo/issue/fields/header" .}} -{{$field := .}} -{{if .Attributes.multiple}} - {{range $i, $opt := .Attributes.options}} -
- {{end}} -{{else}} - -{{end}} + + From f2d38f16c8a7ab99b488e3ff6ed03b514f4c64e3 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 28 Aug 2022 21:27:28 +0800 Subject: [PATCH 12/44] feat: update input style --- modules/issue/template/render.go | 23 +++++++++++---------- templates/repo/issue/fields/checkboxes.tmpl | 13 ++++-------- templates/repo/issue/fields/dropdown.tmpl | 2 +- templates/repo/issue/fields/input.tmpl | 9 +------- templates/repo/issue/fields/textarea.tmpl | 8 +------ 5 files changed, 19 insertions(+), 36 deletions(-) diff --git a/modules/issue/template/render.go b/modules/issue/template/render.go index 4ba7c978fafd4..e3498ab0de984 100644 --- a/modules/issue/template/render.go +++ b/modules/issue/template/render.go @@ -42,25 +42,20 @@ func (f *valuedField) WriteTo(builder *strings.Builder) { } // write label - _, _ = fmt.Fprintf(builder, "### %s\n", f.Label()) + _, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label()) blankPlaceholder := "_No response_\n" // write body switch f.Type { case "checkboxes": - empty := true for _, option := range f.Options() { checked := " " if option.IsChecked() { checked = "x" - empty = true } _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label()) } - if empty { - _, _ = fmt.Fprint(builder, blankPlaceholder) - } case "dropdown": var checkeds []string for _, option := range f.Options() { @@ -144,12 +139,18 @@ func (o *valuedOption) Label() string { } func (o *valuedOption) IsChecked() bool { - 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 + switch o.field.Type { + case "dropdown": + 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 "checkboxes": + return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on" } return false } diff --git a/templates/repo/issue/fields/checkboxes.tmpl b/templates/repo/issue/fields/checkboxes.tmpl index 19ea63f15ef16..b0e25e8fe4098 100644 --- a/templates/repo/issue/fields/checkboxes.tmpl +++ b/templates/repo/issue/fields/checkboxes.tmpl @@ -1,13 +1,8 @@ {{template "repo/issue/fields/header" .}} {{$field := .}} {{range $i, $opt := .Attributes.options}} -
+
+ + +
{{end}} diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl index cb9db39830d0e..0ce8877a18afe 100644 --- a/templates/repo/issue/fields/dropdown.tmpl +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -1,5 +1,5 @@ {{template "repo/issue/fields/header" .}} - - {{if .IsAttachmentEnabled}} -
- {{template "repo/upload" .}} -
- {{end}} +{{end}} +{{if .IsAttachmentEnabled}} +
+ {{template "repo/upload" .}} +
{{end}} diff --git a/templates/repo/issue/fields/header.tmpl b/templates/repo/issue/fields/header.tmpl index 991eef8f8b616..49f20e6e46bd4 100644 --- a/templates/repo/issue/fields/header.tmpl +++ b/templates/repo/issue/fields/header.tmpl @@ -1,10 +1,10 @@ -{{- if .Attributes.label}} +{{if .Attributes.label}}

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

{{end}} -{{- if .Attributes.description}} +{{if .Attributes.description}}
{{RenderMarkdownToHtml .Attributes.description}}
{{end}} diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl index 7c55f3d6bbc6a..582eda402d99a 100644 --- a/templates/repo/issue/fields/textarea.tmpl +++ b/templates/repo/issue/fields/textarea.tmpl @@ -1,2 +1,3 @@ {{template "repo/issue/fields/header" .}} +{{/*FIXME: preview markdown */}} From b0f54725a3ceed8bfc5d084d66531a85f6514d56 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Mon, 29 Aug 2022 11:41:26 +0800 Subject: [PATCH 15/44] chore: fix lint error --- modules/structs/issue.go | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 420ca872dc35b..3ddf4b757e0d4 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -160,8 +160,7 @@ func (it IssueTemplate) Validate() error { for idx, field := range it.Fields { checkStringAttr := func(name string) error { - attr := field.Attributes[name] - if s, ok := attr.(string); !ok || s == "" { + if attr, ok := field.Attributes[name].(string); !ok || attr == "" { return fmt.Errorf( "body[%d]: the '%s' attribute is required and should be string with type %s", idx, name, field.Type, @@ -179,33 +178,29 @@ func (it IssueTemplate) Validate() error { return err } case "checkboxes": - if err := checkStringAttr("label"); err != nil { - } if err := checkStringAttr("label"); err != nil { return err } - attr := field.Attributes["options"] - if options, ok := attr.([]any); !ok { + options, ok := field.Attributes["options"].([]interface{}) + if !ok { return fmt.Errorf( "body[%d]: the '%s' attribute is required and should be array with type %s", idx, "options", field.Type, ) - } else { - for optIdx, option := range options { - if opt, ok := option.(map[any]any); !ok { - return fmt.Errorf( - "body[%d], option[%d]: should be dictionary with type %s", - idx, optIdx, field.Type, - ) - } else { - label := opt["label"] - if s, ok := label.(string); !ok || s == "" { - return fmt.Errorf( - "body[%d], option[%d]: the '%s' is required and should be string with type %s", - idx, optIdx, "label", field.Type, - ) - } - } + } + for optIdx, option := range options { + opt, ok := option.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf( + "body[%d], option[%d]: should be dictionary with type %s", + idx, optIdx, field.Type, + ) + } + if label, ok := opt["label"].(string); !ok || label == "" { + return fmt.Errorf( + "body[%d], option[%d]: the '%s' is required and should be string with type %s", + idx, optIdx, "label", field.Type, + ) } } @@ -227,7 +222,6 @@ func (it IssueTemplate) Valid() bool { // Type returns the type of IssueTemplate, it could be "md", "yaml" or empty for known func (it IssueTemplate) Type() string { if it.Name == "config.yaml" || it.Name == "config.yml" { - // TODO: should it be? // ignore config.yaml which is a special configuration file return "" } From 5e9e60eb5c3ecd48b698eb4652c01b7efb3e27a6 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Mon, 29 Aug 2022 11:47:26 +0800 Subject: [PATCH 16/44] fix: support use description as about in markdown --- modules/issue/template/unmarshal.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 13ce75244e3fb..089fa38c1ca38 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -24,21 +24,28 @@ func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) { FileName: filename, } + // Compatible with treating description as about + compatibleTemplate := &struct { + About string `yaml:"description"` + }{} + if typ := it.Type(); typ == "md" { templateBody, err := markdown.ExtractMetadata(string(content), it) if err != nil { return nil, fmt.Errorf("extract metadata: %w", 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 == "yaml" { if err := yaml.Unmarshal(content, it); err != nil { return nil, fmt.Errorf("yaml unmarshal: %w", err) } if it.About == "" { - // Compatible with treating description as about - compatibleTemplate := &struct { - About string `yaml:"description"` - }{} if err := yaml.Unmarshal(content, compatibleTemplate); err == nil && compatibleTemplate.About != "" { it.About = compatibleTemplate.About } From 1d1d7dc2f58b5bb94fb5ea99298d0d0a1dfc492d Mon Sep 17 00:00:00 2001 From: Jason Song Date: Mon, 29 Aug 2022 11:56:18 +0800 Subject: [PATCH 17/44] fix: add field class in form --- modules/issue/template/unmarshal.go | 1 - templates/repo/issue/fields/checkboxes.tmpl | 18 ++++++++++-------- templates/repo/issue/fields/dropdown.tmpl | 21 +++++++++++---------- templates/repo/issue/fields/input.tmpl | 6 ++++-- templates/repo/issue/fields/markdown.tmpl | 4 +++- templates/repo/issue/fields/textarea.tmpl | 8 +++++--- 6 files changed, 33 insertions(+), 25 deletions(-) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 089fa38c1ca38..af80ee397b194 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -40,7 +40,6 @@ func Unmarshal(filename string, content []byte) (*api.IssueTemplate, error) { it.About = compatibleTemplate.About } } - } else if typ == "yaml" { if err := yaml.Unmarshal(content, it); err != nil { return nil, fmt.Errorf("yaml unmarshal: %w", err) diff --git a/templates/repo/issue/fields/checkboxes.tmpl b/templates/repo/issue/fields/checkboxes.tmpl index b0e25e8fe4098..6dfea77f7c958 100644 --- a/templates/repo/issue/fields/checkboxes.tmpl +++ b/templates/repo/issue/fields/checkboxes.tmpl @@ -1,8 +1,10 @@ -{{template "repo/issue/fields/header" .}} -{{$field := .}} -{{range $i, $opt := .Attributes.options}} -
- - -
-{{end}} +
+ {{template "repo/issue/fields/header" .}} + {{$field := .}} + {{range $i, $opt := .Attributes.options}} +
+ + +
+ {{end}} +
diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl index 0ce8877a18afe..e9708f7e1e21f 100644 --- a/templates/repo/issue/fields/dropdown.tmpl +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -1,12 +1,13 @@ -{{template "repo/issue/fields/header" .}} -