diff --git a/modules/context/repo.go b/modules/context/repo.go index ea40542069991..87b6c740ab030 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "path" + "strconv" "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 @@ -1032,19 +1034,52 @@ func UnitTypes() func(ctx *Context) { } } +func ExtractTemplateFromYaml(templateContent []byte, meta *api.IssueTemplate) (*api.IssueFormTemplate, []string, error) { + var tmpl *api.IssueFormTemplate + err := yaml.Unmarshal(templateContent, &tmpl) + if err != nil { + return nil, nil, err + } + + // Make sure it's valid + if validationErrs := tmpl.Valid(); len(validationErrs) > 0 { + return nil, validationErrs, fmt.Errorf("invalid issue template: %v", validationErrs) + } + + // Fill missing field IDs with the field index + for i, f := range tmpl.Fields { + if f.ID == "" { + tmpl.Fields[i].ID = strconv.FormatInt(int64(i+1), 10) + } + } + + // Copy metadata + if meta != nil { + meta.Name = tmpl.Name + meta.Title = tmpl.Title + meta.About = tmpl.About + meta.Labels = tmpl.Labels + // TODO: meta.Assignees = tmpl.Assignees + meta.Ref = tmpl.Ref + } + + return tmpl, nil, nil +} + // IssueTemplatesFromDefaultBranch checks for issue templates in the repo's default branch -func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { +func (ctx *Context) IssueTemplatesFromDefaultBranch() ([]api.IssueTemplate, map[string][]string) { var issueTemplates []api.IssueTemplate + validationErrs := make(map[string][]string) 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 } } @@ -1055,7 +1090,7 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { } entries, err := tree.ListEntries() if err != nil { - return issueTemplates + return issueTemplates, nil } for _, entry := range entries { if strings.HasSuffix(entry.Name(), ".md") { @@ -1088,14 +1123,54 @@ func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { } it.Content = content it.FileName = entry.Name() + if it.Valid() { + issueTemplates = append(issueTemplates, it) + } else { + fmt.Printf("%#v\n", it) + } + } else if strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml") { + if entry.Blob().Size() >= setting.UI.MaxDisplayFileSize { + log.Debug("Issue form 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() + } + }() + templateContent, err := io.ReadAll(r) + if err != nil { + log.Debug("ReadAll: %v", err) + continue + } + _ = r.Close() + + var it api.IssueTemplate + it.FileName = path.Base(entry.Name()) + + var tmplValidationErrs []string + _, tmplValidationErrs, err = ExtractTemplateFromYaml(templateContent, &it) + if err != nil { + log.Debug("ExtractTemplateFromYaml: %v", err) + if tmplValidationErrs != nil { + validationErrs[path.Base(entry.Name())] = tmplValidationErrs + } + continue + } if it.Valid() { issueTemplates = append(issueTemplates, it) } } } if len(issueTemplates) > 0 { - return issueTemplates + return issueTemplates, validationErrs } } - return issueTemplates + return issueTemplates, validationErrs } diff --git a/modules/structs/issue_form.go b/modules/structs/issue_form.go new file mode 100644 index 0000000000000..ae7dfa2ce9af1 --- /dev/null +++ b/modules/structs/issue_form.go @@ -0,0 +1,100 @@ +// Copyright 2016 The Gogs 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 structs + +import ( + "fmt" + "strings" +) + +type FormField struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Attributes map[string]interface{} `yaml:"attributes"` + Validations map[string]interface{} `yaml:"validations"` +} + +// IssueFormTemplate represents an issue form template for a repository +// swagger:model +type IssueFormTemplate struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + About string `yaml:"description"` + Labels []string `yaml:"labels"` + Assignees []string `yaml:"assignees"` + Ref string `yaml:"ref"` + Fields []FormField `yaml:"body"` + FileName string `yaml:"-"` +} + +// Valid checks whether an IssueFormTemplate is considered valid, e.g. at least name and about, and labels for all fields +func (it IssueFormTemplate) Valid() []string { + // TODO: Localize error messages + // TODO: Add a bunch more validations + var errs []string + + if strings.TrimSpace(it.Name) == "" { + errs = append(errs, "the 'name' field of the issue template are required") + } + if strings.TrimSpace(it.About) == "" { + errs = append(errs, "the 'about' field of the issue template are required") + } + + // Make sure all non-markdown fields have labels + for fieldIdx, field := range it.Fields { + // Make checker functions + checkStringAttr := func(attrName string) { + attr := field.Attributes[attrName] + if attr == nil || strings.TrimSpace(attr.(string)) == "" { + errs = append(errs, fmt.Sprintf( + "(field #%d '%s'): the '%s' attribute is required for fields with type %s", + fieldIdx+1, field.ID, attrName, field.Type, + )) + } + } + checkOptionsStringAttr := func(optionIdx int, option map[interface{}]interface{}, attrName string) { + attr := option[attrName] + if attr == nil || strings.TrimSpace(attr.(string)) == "" { + errs = append(errs, fmt.Sprintf( + "(field #%d '%s', option #%d): the '%s' field is required for options", + fieldIdx+1, field.ID, optionIdx, attrName, + )) + } + } + checkListAttr := func(attrName string, itemChecker func(int, map[interface{}]interface{})) { + attr := field.Attributes[attrName] + if attr == nil { + errs = append(errs, fmt.Sprintf( + "(field #%d '%s'): the '%s' attribute is required for fields with type %s", + fieldIdx+1, field.ID, attrName, field.Type, + )) + } else { + for i, item := range attr.([]interface{}) { + itemChecker(i, item.(map[interface{}]interface{})) + } + } + } + + // Make sure each field has its attributes + switch field.Type { + case "markdown": + checkStringAttr("value") + case "textarea", "input", "dropdown": + checkStringAttr("label") + case "checkboxes": + checkStringAttr("label") + checkListAttr("options", func(i int, item map[interface{}]interface{}) { + checkOptionsStringAttr(i, item, "label") + }) + default: + errs = append(errs, fmt.Sprintf( + "(field #%d '%s'): unknown type '%s'", + fieldIdx+1, field.ID, field.Type, + )) + } + } + + return errs +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 2330057f87202..f0ab830e7afea 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -75,6 +75,7 @@ forks = Forks activities = Activities pull_requests = Pull Requests issues = Issues +issue = Issue milestones = Milestones ok = OK @@ -1202,6 +1203,7 @@ issues.filter_labels = Filter Label issues.filter_reviewers = Filter Reviewer issues.new = New Issue issues.new.title_empty = Title cannot be empty +issues.new.invalid_form_values = Invalid form values issues.new.labels = Labels issues.new.add_labels_title = Apply labels issues.new.no_label = No Label diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index cdd1f7d5c4a7c..f8cf75f719aba 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -1080,5 +1080,6 @@ func GetIssueTemplates(ctx *context.APIContext) { // "200": // "$ref": "#/responses/IssueTemplates" - ctx.JSON(http.StatusOK, ctx.IssueTemplatesFromDefaultBranch()) + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.JSON(http.StatusOK, issueTemplates) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index ad25a94e13b19..910e2b18ae2cc 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -64,6 +64,8 @@ const ( tplReactions base.TplName = "repo/issue/view_content/reactions" issueTemplateKey = "IssueTemplate" + issueFormTemplateKey = "IssueFormTemplate" + issueFormErrorsKey = "IssueTemplateErrors" issueTemplateTitleKey = "IssueTemplateTitle" ) @@ -407,7 +409,8 @@ func Issues(ctx *context.Context) { } ctx.Data["Title"] = ctx.Tr("repo.issues") ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 } issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), util.OptionalBoolOf(isPullList)) @@ -722,16 +725,16 @@ 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 { +func getFileContentFromDefaultBranch(repo *context.Repository, filename string) (string, bool) { + if repo.Commit == nil { var err error - ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + repo.Commit, err = repo.GitRepo.GetBranchCommit(repo.Repository.DefaultBranch) if err != nil { return "", false } } - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(filename) + entry, err := repo.Commit.GetTreeEntryByPath(filename) if err != nil { return "", false } @@ -750,60 +753,101 @@ func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (str return string(bytes), true } -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) { +func getTemplate(repo *context.Repository, template string, possibleDirs, possibleFiles []string) (*api.IssueTemplate, string, *api.IssueFormTemplate, map[string][]string, error) { + validationErrs := make(map[string][]string) + + // Add `possibleFiles` and each `{possibleDirs}/{template}` to `templateCandidates` templateCandidates := make([]string, 0, len(possibleFiles)) - if ctx.FormString("template") != "" { + if template != "" { for _, dirName := range possibleDirs { - templateCandidates = append(templateCandidates, path.Join(dirName, ctx.FormString("template"))) + templateCandidates = append(templateCandidates, path.Join(dirName, template)) } } templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback + for _, filename := range templateCandidates { - templateContent, found := getFileContentFromDefaultBranch(ctx, filename) + // Read each template + templateContent, found := getFileContentFromDefaultBranch(repo, filename) if found { - var meta api.IssueTemplate - templateBody, err := markdown.ExtractMetadata(templateContent, &meta) + meta := api.IssueTemplate{FileName: filename} + var templateBody string + var formTemplateBody *api.IssueFormTemplate + var err error + + if strings.HasSuffix(filename, ".md") { + // Parse markdown template + templateBody, err = markdown.ExtractMetadata(templateContent, &meta) + } else if strings.HasSuffix(filename, ".yaml") || strings.HasSuffix(filename, ".yml") { + // Parse yaml (form) template + var tmplValidationErrs []string + formTemplateBody, tmplValidationErrs, err = context.ExtractTemplateFromYaml([]byte(templateContent), &meta) + if err == nil { + formTemplateBody.FileName = path.Base(filename) + } else if tmplValidationErrs != nil { + validationErrs[path.Base(filename)] = tmplValidationErrs + } + } else { + err = errors.New("invalid template type") + } if err != nil { - log.Debug("could not extract metadata from %s [%s]: %v", filename, ctx.Repo.Repository.FullName(), err) - ctx.Data[ctxDataKey] = templateContent - return + log.Debug("could not extract metadata from %s [%s]: %v", filename, repo.Repository.FullName(), err) } - 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 - } - } + return &meta, templateBody, formTemplateBody, validationErrs, err + } + } + + return nil, "", nil, validationErrs, errors.New("no template found") +} + +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs, possibleFiles []string) { + templateMeta, templateBody, formTemplateBody, validationErrs, err := getTemplate(ctx.Repo, ctx.FormString("template"), possibleDirs, possibleFiles) + if err != nil { + return + } + + if formTemplateBody != nil { + ctx.Data[issueFormTemplateKey] = formTemplateBody + } + if len(validationErrs) > 0 { + ctx.Data[issueFormErrorsKey] = validationErrs + } + + ctx.Data[issueTemplateTitleKey] = templateMeta.Title + ctx.Data[ctxDataKey] = templateBody + + labelIDs := make([]string, 0, len(templateMeta.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 templateMeta.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"] = templateMeta.Ref + ctx.Data["RefEndName"] = git.RefEndName(templateMeta.Ref) } // NewIssue render creating issue page func NewIssue(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 ctx.Data["RequireTribute"] = true ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes title := ctx.FormString("title") @@ -860,7 +904,10 @@ func NewIssueChooseTemplate(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - issueTemplates := ctx.IssueTemplatesFromDefaultBranch() + issueTemplates, validationErrs := ctx.IssueTemplatesFromDefaultBranch() + if len(validationErrs) > 0 { + ctx.Data[issueFormErrorsKey] = validationErrs + } ctx.Data["IssueTemplates"] = issueTemplates if len(issueTemplates) == 0 { @@ -997,12 +1044,86 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull return labelIDs, assigneeIDs, milestoneID, form.ProjectID } +// 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) { + // Skip if submitted without a form + if form.Has("content") || !form.Has("form-type") { + return "", nil + } + + // Fetch template + _, _, formTemplateBody, _, err := getTemplate( + ctx.Repo, + form.Get("form-type"), + context.IssueTemplateDirCandidates, + IssueTemplateCandidates, + ) + if err != nil { + return "", err + } + if formTemplateBody == nil { + return "", errors.New("no form template found") + } + + // Render values + result := "" + for _, field := range formTemplateBody.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 + panic(fmt.Errorf("Invalid field type: '%s'", field.Type)) + } + } + } + + return result, nil +} + // NewIssuePost response for creating new issue func NewIssuePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateIssueForm) ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -1031,6 +1152,19 @@ func NewIssuePost(ctx *context.Context) { return } + // If the issue submitted is a form, render it to Markdown + issueContents, err := renderIssueFormValues(ctx, &ctx.Req.Form) + if err != nil { + ctx.Flash.ErrorMsg = ctx.Tr("repo.issues.new.invalid_form_values") + ctx.Data["Flash"] = ctx.Flash + NewIssue(ctx) + return + } + if issueContents == "" { + // Not a form + issueContents = form.Content + } + issue := &issues_model.Issue{ RepoID: repo.ID, Repo: repo, @@ -1038,7 +1172,7 @@ func NewIssuePost(ctx *context.Context) { PosterID: ctx.Doer.ID, Poster: ctx.Doer, MilestoneID: milestoneID, - Content: form.Content, + Content: issueContents, Ref: form.Ref, } @@ -1185,7 +1319,8 @@ func ViewIssue(ctx *context.Context) { return } ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 } if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index 1e75bd79fb27c..607fe63461858 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -290,7 +290,8 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Milestone"] = milestone issues(ctx, milestoneID, 0, util.OptionalBoolNone) - ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 + issueTemplates, _ := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) diff --git a/templates/repo/issue/choose.tmpl b/templates/repo/issue/choose.tmpl index bbddd6d9a6ce3..c03a308f701ae 100644 --- a/templates/repo/issue/choose.tmpl +++ b/templates/repo/issue/choose.tmpl @@ -30,6 +30,20 @@ + {{- if .IssueTemplateErrors}} +
+ {{end}} {{template "base/footer" .}} diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index c1ca69dfb3e2f..05f6886eb2cfa 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,19 +1,84 @@ - -