Issue templates directory (#11450)

* Issue templates

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Add some comments, appease the linter

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Add docs and re-use dir candidates

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Add default labels to issue templates

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Generate swagger

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Suggested changes

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Update issue.go

* Suggestions

Signed-off-by: jolheiser <john.olheiser@gmail.com>

* Extract metadata from legacy if possible

Signed-off-by: jolheiser <john.olheiser@gmail.com>

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
John Olheiser 2020-09-11 09:48:39 -05:00 committed by GitHub
parent dd1a651b58
commit 26c4a049da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 381 additions and 17 deletions

View File

@ -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`.

View File

@ -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
}

View File

@ -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
}

View File

@ -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) != ""
}

View File

@ -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

View File

@ -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())
})

View File

@ -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())
}

View File

@ -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 {

View File

@ -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)

View File

@ -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) {

View File

@ -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)

View File

@ -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.

View File

@ -0,0 +1,25 @@
{{template "base/head" .}}
<div class="repository new issue">
{{template "repo/header" .}}
<div class="ui container">
<div class="navbar">
{{template "repo/issue/navbar" .}}
</div>
<div class="ui divider"></div>
{{range .IssueTemplates}}
<div class="ui attached segment">
<div class="ui two column grid">
<div class="column left aligned">
<strong>{{.Name | RenderEmojiPlain}}</strong>
<br/>{{.About | RenderEmojiPlain}}
</div>
<div class="column right aligned">
<a href="{{$.RepoLink}}/issues/new?template={{.FileName}}{{if $.milestone}}&milestone={{$.milestone}}{{end}}" class="ui green button">{{$.i18n.Tr "repo.issues.choose.get_started"}}</a>
</div>
</div>
</div>
{{end}}
<a href="{{.RepoLink}}/issues/new{{if .milestone}}?milestone={{.milestone}}{{end}}">{{.i18n.Tr "repo.issues.choose.blank"}}</a>
</div>
</div>
{{template "base/footer" .}}

View File

@ -12,7 +12,7 @@
{{if not .Repository.IsArchived}}
<div class="column right aligned">
{{if .PageIsIssueList}}
<a class="ui green button" href="{{.RepoLink}}/issues/new">{{.i18n.Tr "repo.issues.new"}}</a>
<a class="ui green button" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{.i18n.Tr "repo.issues.new"}}</a>
{{else}}
<a class="ui green button {{if not .PullRequestCtx.Allowed}}disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.Repository.Link}}/compare/{{.Repository.DefaultBranch | EscapePound}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{.Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | EscapePound}}{{end}}">{{.i18n.Tr "repo.pulls.new"}}</a>
{{end}}

View File

@ -16,7 +16,7 @@
{{if or .CanWriteIssues .CanWritePulls}}
<a class="ui grey button" href="{{.RepoLink}}/milestones/{{.MilestoneID}}/edit">{{.i18n.Tr "repo.milestones.edit"}}</a>
{{end}}
<a class="ui green button" href="{{.RepoLink}}/issues/new?milestone={{.MilestoneID}}">{{.i18n.Tr "repo.issues.new"}}</a>
<a class="ui green button" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}?milestone={{.MilestoneID}}">{{.i18n.Tr "repo.issues.new"}}</a>
</div>
{{end}}
</div>

View File

@ -13,7 +13,7 @@
</a>
<div class="ui segment content">
<div class="field">
<input name="title" id="issue_title" placeholder="{{.i18n.Tr "repo.milestones.title"}}" value="{{.title}}" tabindex="3" autofocus required maxlength="255">
<input name="title" id="issue_title" placeholder="{{.i18n.Tr "repo.milestones.title"}}" value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}" tabindex="3" autofocus required maxlength="255">
{{if .PageIsComparePull}}
<div class="title_wip_desc" data-wip-prefixes="{{Json .PullRequestWorkInProgressPrefixes}}">{{.i18n.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0| Escape) | Safe}}</div>
{{end}}

View File

@ -9,7 +9,7 @@
{{if not .Repository.IsArchived}}
<div class="column right aligned">
{{if .PageIsIssueList}}
<a class="ui green button" href="{{.RepoLink}}/issues/new">{{.i18n.Tr "repo.issues.new"}}</a>
<a class="ui green button" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{.i18n.Tr "repo.issues.new"}}</a>
{{else}}
<a class="ui green button {{if not .PullRequestCtx.Allowed}}disabled{{end}}" href="{{.RepoLink}}/compare/{{.BranchName | EscapePound}}...{{.PullRequestCtx.HeadInfo | EscapePound}}">{{.i18n.Tr "repo.pulls.new"}}</a>
{{end}}

View File

@ -3852,6 +3852,39 @@
}
}
},
"/repos/{owner}/{repo}/issue_templates": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get available issue templates for a repository",
"operationId": "repoGetIssueTemplates",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/IssueTemplates"
}
}
}
},
"/repos/{owner}/{repo}/issues": {
"get": {
"produces": [
@ -13439,6 +13472,40 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"IssueTemplate": {
"description": "IssueTemplate represents an issue template for a repository",
"type": "object",
"properties": {
"about": {
"type": "string",
"x-go-name": "About"
},
"content": {
"type": "string",
"x-go-name": "Content"
},
"file_name": {
"type": "string",
"x-go-name": "FileName"
},
"labels": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Labels"
},
"name": {
"type": "string",
"x-go-name": "Name"
},
"title": {
"type": "string",
"x-go-name": "Title"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"Label": {
"description": "Label a label to an issue or a pr",
"type": "object",
@ -15480,6 +15547,15 @@
}
}
},
"IssueTemplates": {
"description": "IssueTemplates",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/IssueTemplate"
}
}
},
"Label": {
"description": "Label",
"schema": {