diff --git a/docs/content/doc/usage/issue-pull-request-templates.en-us.md b/docs/content/doc/usage/issue-pull-request-templates.en-us.md index a4fc51b81fc..4f5da04cb6d 100644 --- a/docs/content/doc/usage/issue-pull-request-templates.en-us.md +++ b/docs/content/doc/usage/issue-pull-request-templates.en-us.md @@ -41,4 +41,39 @@ Possible file names for PR templates: * .github/pull_request_template.md -Additionally, the New Issue page URL can be suffixed with `?body=Issue+Text` and the form will be populated with that string. This string will be used instead of the template if there is one. +Additionally, the New Issue page URL can be suffixed with `?title=Issue+Title&body=Issue+Text` and the form will be populated with those strings. Those strings will be used instead of the template if there is one. + +# Issue Template Directory + +Alternatively, users can create multiple issue templates inside a special directory and allow users to choose one that more specifically +addresses their problem. + +Possible directory names for issue templates: + +* ISSUE_TEMPLATE +* issue_template +* .gitea/ISSUE_TEMPLATE +* .gitea/issue_template +* .github/ISSUE_TEMPLATE +* .github/issue_template +* .gitlab/ISSUE_TEMPLATE +* .gitlab/issue_template + +Inside the directory can be multiple issue templates with the form + +```markdown +----- +name: "Template Name" +about: "This template is for testing!" +title: "[TEST] " +labels: + - bug + - "help needed" +----- +This is the template! +``` + +In the above example, when a user is presented with the list of issues they can submit, this would show as `Template Name` with the description +`This template is for testing!`. When submitting an issue with the above example, the issue title would be pre-populated with +`[TEST] ` while the issue body would be pre-populated with `This is the template!`. The issue would also be assigned two labels, +`bug` and `help needed`. diff --git a/modules/context/repo.go b/modules/context/repo.go index 4aac0c05aa8..2c773614606 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -16,13 +16,27 @@ import ( "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "gitea.com/macaron/macaron" "github.com/editorconfig/editorconfig-core-go/v2" "github.com/unknwon/com" ) +// IssueTemplateDirCandidates issue templates directory +var IssueTemplateDirCandidates = []string{ + "ISSUE_TEMPLATE", + "issue_template", + ".gitea/ISSUE_TEMPLATE", + ".gitea/issue_template", + ".github/ISSUE_TEMPLATE", + ".github/issue_template", + ".gitlab/ISSUE_TEMPLATE", + ".gitlab/issue_template", +} + // PullRequest contains informations to make a pull request type PullRequest struct { BaseRepo *models.Repository @@ -821,3 +835,60 @@ func UnitTypes() macaron.Handler { ctx.Data["UnitTypeProjects"] = models.UnitTypeProjects } } + +// IssueTemplatesFromDefaultBranch checks for issue templates in the repo's default branch +func (ctx *Context) IssueTemplatesFromDefaultBranch() []api.IssueTemplate { + var issueTemplates []api.IssueTemplate + 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 + } + } + + for _, dirName := range IssueTemplateDirCandidates { + tree, err := ctx.Repo.Commit.SubTree(dirName) + if err != nil { + continue + } + entries, err := tree.ListEntries() + if err != nil { + 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 + } + defer r.Close() + data, err := ioutil.ReadAll(r) + if err != nil { + log.Debug("ReadAll: %v", err) + continue + } + 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 len(issueTemplates) > 0 { + return issueTemplates + } + } + return issueTemplates +} diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go new file mode 100644 index 00000000000..ca95e4d26a6 --- /dev/null +++ b/modules/markup/markdown/meta.go @@ -0,0 +1,49 @@ +// Copyright 2020 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 markdown + +import ( + "errors" + "strings" + + "gopkg.in/yaml.v2" +) + +func isYAMLSeparator(line string) bool { + line = strings.TrimSpace(line) + for i := 0; i < len(line); i++ { + if line[i] != '-' { + return false + } + } + return len(line) > 2 +} + +// ExtractMetadata consumes a markdown file, parses YAML frontmatter, +// and returns the frontmatter metadata separated from the markdown content +func ExtractMetadata(contents string, out interface{}) (string, error) { + var front, body []string + var seps int + lines := strings.Split(contents, "\n") + for idx, line := range lines { + if seps == 2 { + front, body = lines[:idx], lines[idx:] + break + } + if isYAMLSeparator(line) { + seps++ + continue + } + } + + if len(front) == 0 && len(body) == 0 { + return "", errors.New("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/modules/structs/issue.go b/modules/structs/issue.go index dc633dedce1..54b0f31d8a5 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -5,6 +5,7 @@ package structs import ( + "strings" "time" ) @@ -119,3 +120,19 @@ type IssueDeadline struct { // swagger:strfmt date-time 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"` + Content string `json:"content" yaml:"-"` + FileName string `json:"file_name" yaml:"-"` +} + +// Valid checks whether an IssueTemplate is considered valid, e.g. at least name and about +func (it IssueTemplate) Valid() bool { + return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != "" +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6d0439de3cb..2f5bd002da8 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -939,6 +939,8 @@ issues.new.clear_assignees = Clear assignees issues.new.no_assignees = No Assignees issues.new.no_reviewers = No reviewers issues.new.add_reviewer_title = Request review +issues.choose.get_started = Get Started +issues.choose.blank = Open a blank issue issues.no_ref = No Branch/Tag Specified issues.create = Create Issue issues.new_label = New Label diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9e856257705..8b3a7545c63 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -866,6 +866,7 @@ func RegisterRoutes(m *macaron.Macaron) { Delete(reqToken(), repo.DeleteTopic) }, reqAdmin()) }, reqAnyRepoReader()) + m.Get("/issue_templates", context.ReferencesGitRepo(false), repo.GetIssueTemplates) m.Get("/languages", reqRepoReader(models.UnitTypeCode), repo.GetLanguages) }, repoAssignment()) }) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 5ebc7f251bc..35062500f74 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -812,3 +812,28 @@ func Delete(ctx *context.APIContext) { log.Trace("Repository deleted: %s/%s", owner.Name, repo.Name) ctx.Status(http.StatusNoContent) } + +// GetIssueTemplates returns the issue templates for a repository +func GetIssueTemplates(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issue_templates repository repoGetIssueTemplates + // --- + // summary: Get available issue templates for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/IssueTemplates" + + ctx.JSON(http.StatusOK, ctx.IssueTemplatesFromDefaultBranch()) +} diff --git a/routers/api/v1/swagger/issue.go b/routers/api/v1/swagger/issue.go index b12ea0096a4..0f2f5720208 100644 --- a/routers/api/v1/swagger/issue.go +++ b/routers/api/v1/swagger/issue.go @@ -85,6 +85,13 @@ type swaggerIssueDeadline struct { Body api.IssueDeadline `json:"body"` } +// IssueTemplates +// swagger:response IssueTemplates +type swaggerIssueTemplates struct { + // in:body + Body []api.IssueTemplate `json:"body"` +} + // StopWatch // swagger:response StopWatch type swaggerResponseStopWatch struct { diff --git a/routers/repo/compare.go b/routers/repo/compare.go index f8a18f06967..9329b5a1d29 100644 --- a/routers/repo/compare.go +++ b/routers/repo/compare.go @@ -577,7 +577,7 @@ func CompareDiff(ctx *context.Context) { ctx.Data["RequireTribute"] = true ctx.Data["RequireSimpleMDE"] = true ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes - setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) + setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates) renderAttachmentSettings(ctx) ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypePullRequests) diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 71c0488972c..7c4f2cea9b8 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -11,6 +11,7 @@ import ( "fmt" "io/ioutil" "net/http" + "path" "strconv" "strings" @@ -36,13 +37,15 @@ import ( const ( tplAttachment base.TplName = "repo/issue/view_content/attachments" - tplIssues base.TplName = "repo/issue/list" - tplIssueNew base.TplName = "repo/issue/new" - tplIssueView base.TplName = "repo/issue/view" + tplIssues base.TplName = "repo/issue/list" + tplIssueNew base.TplName = "repo/issue/new" + tplIssueChoose base.TplName = "repo/issue/choose" + tplIssueView base.TplName = "repo/issue/view" tplReactions base.TplName = "repo/issue/view_content/reactions" - issueTemplateKey = "IssueTemplate" + issueTemplateKey = "IssueTemplate" + issueTemplateTitleKey = "IssueTemplateTitle" ) var ( @@ -356,6 +359,7 @@ func Issues(ctx *context.Context) { } ctx.Data["Title"] = ctx.Tr("repo.issues") ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 } issues(ctx, ctx.QueryInt64("milestone"), ctx.QueryInt64("project"), util.OptionalBoolOf(isPullList)) @@ -515,11 +519,41 @@ func getFileContentFromDefaultBranch(ctx *context.Context, filename string) (str return string(bytes), true } -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) { - for _, filename := range possibleFiles { - content, found := getFileContentFromDefaultBranch(ctx, filename) +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleDirs []string, possibleFiles []string) { + templateCandidates := make([]string, 0, len(possibleFiles)) + if ctx.Query("template") != "" { + for _, dirName := range possibleDirs { + templateCandidates = append(templateCandidates, path.Join(dirName, ctx.Query("template"))) + } + } + 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 { - ctx.Data[ctxDataKey] = content + 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 := models.GetLabelsByRepoID(ctx.Repo.Repository.ID, "", models.ListOptions{}); err == nil { + for _, metaLabel := range meta.Labels { + for _, repoLabel := range repoLabels { + if strings.EqualFold(repoLabel.Name, metaLabel) { + repoLabel.IsChecked = true + labelIDs = append(labelIDs, fmt.Sprintf("%d", repoLabel.ID)) + break + } + } + } + ctx.Data["Labels"] = repoLabels + } + ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0 + ctx.Data["label_ids"] = strings.Join(labelIDs, ",") return } } @@ -529,10 +563,13 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles func NewIssue(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 ctx.Data["RequireHighlightJS"] = true ctx.Data["RequireSimpleMDE"] = true ctx.Data["RequireTribute"] = true ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + title := ctx.Query("title") + ctx.Data["TitleQuery"] = title body := ctx.Query("body") ctx.Data["BodyQuery"] = body ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) @@ -562,10 +599,10 @@ func NewIssue(ctx *context.Context) { } - setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) renderAttachmentSettings(ctx) RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) + setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates) if ctx.Written() { return } @@ -575,6 +612,19 @@ func NewIssue(ctx *context.Context) { ctx.HTML(200, tplIssueNew) } +// NewIssueChooseTemplate render creating issue from template page +func NewIssueChooseTemplate(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.issues.new") + ctx.Data["PageIsIssueList"] = true + ctx.Data["milestone"] = ctx.QueryInt64("milestone") + + issueTemplates := ctx.IssueTemplatesFromDefaultBranch() + ctx.Data["NewIssueChooseTemplate"] = len(issueTemplates) > 0 + ctx.Data["IssueTemplates"] = issueTemplates + + ctx.HTML(200, tplIssueChoose) +} + // ValidateRepoMetas check and returns repository's meta informations func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { var ( @@ -676,6 +726,7 @@ func ValidateRepoMetas(ctx *context.Context, form auth.CreateIssueForm, isPull b func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 ctx.Data["RequireHighlightJS"] = true ctx.Data["RequireSimpleMDE"] = true ctx.Data["ReadOnly"] = false @@ -814,6 +865,7 @@ func ViewIssue(ctx *context.Context) { return } ctx.Data["PageIsIssueList"] = true + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 } if issue.IsPull && !ctx.Repo.CanRead(models.UnitTypeIssues) { diff --git a/routers/repo/milestone.go b/routers/repo/milestone.go index f48c5de12e3..96f5b4e5f00 100644 --- a/routers/repo/milestone.go +++ b/routers/repo/milestone.go @@ -264,6 +264,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Milestone"] = milestone issues(ctx, milestoneID, 0, util.OptionalBoolNone) + ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0 ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 779e8614b3b..247835c0622 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -723,8 +723,11 @@ func RegisterRoutes(m *macaron.Macaron) { // Grouping for those endpoints that do require authentication m.Group("/:username/:reponame", func() { m.Group("/issues", func() { - m.Combo("/new").Get(context.RepoRef(), repo.NewIssue). - Post(bindIgnErr(auth.CreateIssueForm{}), repo.NewIssuePost) + m.Group("/new", func() { + m.Combo("").Get(context.RepoRef(), repo.NewIssue). + Post(bindIgnErr(auth.CreateIssueForm{}), repo.NewIssuePost) + m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) + }) }, context.RepoMustNotBeArchived(), reqRepoIssueReader) // FIXME: should use different URLs but mostly same logic for comments of issue and pull reuqest. // So they can apply their own enable/disable logic on routers. diff --git a/templates/repo/issue/choose.tmpl b/templates/repo/issue/choose.tmpl new file mode 100644 index 00000000000..57f69f8c14c --- /dev/null +++ b/templates/repo/issue/choose.tmpl @@ -0,0 +1,25 @@ +{{template "base/head" .}} +