Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Implement Issue forms #20778

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 81 additions & 6 deletions modules/context/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"path"
"strconv"
"strings"

"code.gitea.io/gitea/models"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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") {
Expand Down Expand Up @@ -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
}
100 changes: 100 additions & 0 deletions modules/structs/issue_form.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ forks = Forks
activities = Activities
pull_requests = Pull Requests
issues = Issues
issue = Issue
milestones = Milestones

ok = OK
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion routers/api/v1/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading