Skip to content

Commit f384b13

Browse files
authored
Implement Issue Config (#20956)
Closes #20955 This PR adds the possibility to disable blank Issues, when the Repo has templates. This can be done by creating the file `.gitea/issue_config.yaml` with the content `blank_issues_enabled` in the Repo.
1 parent 5cd1d6c commit f384b13

File tree

12 files changed

+463
-13
lines changed

12 files changed

+463
-13
lines changed

docs/content/doc/usage/issue-pull-request-templates.en-us.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ Possible file names for issue templates:
5050
- `.github/issue_template.yaml`
5151
- `.github/issue_template.yml`
5252

53+
Possible file names for issue config:
54+
55+
- `.gitea/ISSUE_TEMPLATE/config.yaml`
56+
- `.gitea/ISSUE_TEMPLATE/config.yml`
57+
- `.gitea/issue_template/config.yaml`
58+
- `.gitea/issue_template/config.yml`
59+
- `.github/ISSUE_TEMPLATE/config.yaml`
60+
- `.github/ISSUE_TEMPLATE/config.yml`
61+
- `.github/issue_template/config.yaml`
62+
- `.github/issue_template/config.yml`
63+
5364
Possible file names for PR templates:
5465

5566
- `PULL_REQUEST_TEMPLATE.md`
@@ -267,3 +278,30 @@ For each value in the options array, you can set the following keys.
267278
|----------|------------------------------------------------------------------------------------------------------------------------------------------|----------|---------|---------|---------|
268279
| label | The identifier for the option, which is displayed in the form. Markdown is supported for bold or italic text formatting, and hyperlinks. | Required | String | - | - |
269280
| required | Prevents form submission until element is completed. | Optional | Boolean | false | - |
281+
282+
## Syntax for issue config
283+
284+
This is a example for a issue config file
285+
286+
```yaml
287+
blank_issues_enabled: true
288+
contact_links:
289+
- name: Gitea
290+
url: https://gitea.io
291+
about: Visit the Gitea Website
292+
```
293+
294+
### Possible Options
295+
296+
| Key | Description | Type | Default |
297+
|----------------------|-------------------------------------------------------------------------------------------------------|--------------------|----------------|
298+
| blank_issues_enabled | If set to false, the User is forced to use a Template | Boolean | true |
299+
| contact_links | Custom Links to show in the Choose Box | Contact Link Array | Empty Array |
300+
301+
### Contact Link
302+
303+
| Key | Description | Type | Required |
304+
|----------------------|-------------------------------------------------------------------------------------------------------|---------|----------|
305+
| name | the name of your link | String | true |
306+
| url | The URL of your Link | String | true |
307+
| about | A short description of your Link | String | true |

modules/context/repo.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"fmt"
1010
"html"
11+
"io"
1112
"net/http"
1213
"net/url"
1314
"path"
@@ -33,6 +34,7 @@ import (
3334
asymkey_service "code.gitea.io/gitea/services/asymkey"
3435

3536
"github.com/editorconfig/editorconfig-core-go/v2"
37+
"gopkg.in/yaml.v3"
3638
)
3739

3840
// IssueTemplateDirCandidates issue templates directory
@@ -47,6 +49,13 @@ var IssueTemplateDirCandidates = []string{
4749
".gitlab/issue_template",
4850
}
4951

52+
var IssueConfigCandidates = []string{
53+
".gitea/ISSUE_TEMPLATE/config",
54+
".gitea/issue_template/config",
55+
".github/ISSUE_TEMPLATE/config",
56+
".github/issue_template/config",
57+
}
58+
5059
// PullRequest contains information to make a pull request
5160
type PullRequest struct {
5261
BaseRepo *repo_model.Repository
@@ -1088,3 +1097,108 @@ func (ctx *Context) IssueTemplatesErrorsFromDefaultBranch() ([]*api.IssueTemplat
10881097
}
10891098
return issueTemplates, invalidFiles
10901099
}
1100+
1101+
func GetDefaultIssueConfig() api.IssueConfig {
1102+
return api.IssueConfig{
1103+
BlankIssuesEnabled: true,
1104+
ContactLinks: make([]api.IssueConfigContactLink, 0),
1105+
}
1106+
}
1107+
1108+
// GetIssueConfig loads the given issue config file.
1109+
// It never returns a nil config.
1110+
func (r *Repository) GetIssueConfig(path string, commit *git.Commit) (api.IssueConfig, error) {
1111+
if r.GitRepo == nil {
1112+
return GetDefaultIssueConfig(), nil
1113+
}
1114+
1115+
var err error
1116+
1117+
treeEntry, err := commit.GetTreeEntryByPath(path)
1118+
if err != nil {
1119+
return GetDefaultIssueConfig(), err
1120+
}
1121+
1122+
reader, err := treeEntry.Blob().DataAsync()
1123+
if err != nil {
1124+
log.Debug("DataAsync: %v", err)
1125+
return GetDefaultIssueConfig(), nil
1126+
}
1127+
1128+
defer reader.Close()
1129+
1130+
configContent, err := io.ReadAll(reader)
1131+
if err != nil {
1132+
return GetDefaultIssueConfig(), err
1133+
}
1134+
1135+
issueConfig := api.IssueConfig{}
1136+
if err := yaml.Unmarshal(configContent, &issueConfig); err != nil {
1137+
return GetDefaultIssueConfig(), err
1138+
}
1139+
1140+
for pos, link := range issueConfig.ContactLinks {
1141+
if link.Name == "" {
1142+
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing name key", pos+1)
1143+
}
1144+
1145+
if link.URL == "" {
1146+
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing url key", pos+1)
1147+
}
1148+
1149+
if link.About == "" {
1150+
return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing about key", pos+1)
1151+
}
1152+
1153+
_, err = url.ParseRequestURI(link.URL)
1154+
if err != nil {
1155+
return GetDefaultIssueConfig(), fmt.Errorf("%s is not a valid URL", link.URL)
1156+
}
1157+
}
1158+
1159+
return issueConfig, nil
1160+
}
1161+
1162+
// IssueConfigFromDefaultBranch returns the issue config for this repo.
1163+
// It never returns a nil config.
1164+
func (ctx *Context) IssueConfigFromDefaultBranch() (api.IssueConfig, error) {
1165+
if ctx.Repo.Repository.IsEmpty {
1166+
return GetDefaultIssueConfig(), nil
1167+
}
1168+
1169+
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
1170+
if err != nil {
1171+
return GetDefaultIssueConfig(), err
1172+
}
1173+
1174+
for _, configName := range IssueConfigCandidates {
1175+
if _, err := commit.GetTreeEntryByPath(configName + ".yaml"); err == nil {
1176+
return ctx.Repo.GetIssueConfig(configName+".yaml", commit)
1177+
}
1178+
1179+
if _, err := commit.GetTreeEntryByPath(configName + ".yml"); err == nil {
1180+
return ctx.Repo.GetIssueConfig(configName+".yml", commit)
1181+
}
1182+
}
1183+
1184+
return GetDefaultIssueConfig(), nil
1185+
}
1186+
1187+
// IsIssueConfig returns if the given path is a issue config file.
1188+
func (r *Repository) IsIssueConfig(path string) bool {
1189+
for _, configName := range IssueConfigCandidates {
1190+
if path == configName+".yaml" || path == configName+".yml" {
1191+
return true
1192+
}
1193+
}
1194+
return false
1195+
}
1196+
1197+
func (ctx *Context) HasIssueTemplatesOrContactLinks() bool {
1198+
if len(ctx.IssueTemplatesFromDefaultBranch()) > 0 {
1199+
return true
1200+
}
1201+
1202+
issueConfig, _ := ctx.IssueConfigFromDefaultBranch()
1203+
return len(issueConfig.ContactLinks) > 0
1204+
}

modules/structs/issue.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ func (l *IssueTemplateLabels) UnmarshalYAML(value *yaml.Node) error {
190190
return fmt.Errorf("line %d: cannot unmarshal %s into IssueTemplateLabels", value.Line, value.ShortTag())
191191
}
192192

193+
type IssueConfigContactLink struct {
194+
Name string `json:"name" yaml:"name"`
195+
URL string `json:"url" yaml:"url"`
196+
About string `json:"about" yaml:"about"`
197+
}
198+
199+
type IssueConfig struct {
200+
BlankIssuesEnabled bool `json:"blank_issues_enabled" yaml:"blank_issues_enabled"`
201+
ContactLinks []IssueConfigContactLink `json:"contact_links" yaml:"contact_links"`
202+
}
203+
204+
type IssueConfigValidation struct {
205+
Valid bool `json:"valid"`
206+
Message string `json:"message"`
207+
}
208+
193209
// IssueTemplateType defines issue template type
194210
type IssueTemplateType string
195211

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,10 +1272,12 @@ issues.new.no_assignees = No Assignees
12721272
issues.new.no_reviewers = No reviewers
12731273
issues.new.add_reviewer_title = Request review
12741274
issues.choose.get_started = Get Started
1275+
issues.choose.open_external_link = Open
12751276
issues.choose.blank = Default
12761277
issues.choose.blank_about = Create an issue from default template.
12771278
issues.choose.ignore_invalid_templates = Invalid templates have been ignored
12781279
issues.choose.invalid_templates = %v invalid template(s) found
1280+
issues.choose.invalid_config = The issue config contains errors:
12791281
issues.no_ref = No Branch/Tag Specified
12801282
issues.create = Create Issue
12811283
issues.new_label = New Label

routers/api/v1/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,8 @@ func Routes(ctx gocontext.Context) *web.Route {
11691169
}, reqAdmin())
11701170
}, reqAnyRepoReader())
11711171
m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates)
1172+
m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig)
1173+
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
11721174
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
11731175
}, repoAssignment())
11741176
})

routers/api/v1/repo/repo.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,3 +1144,58 @@ func GetIssueTemplates(ctx *context.APIContext) {
11441144

11451145
ctx.JSON(http.StatusOK, ctx.IssueTemplatesFromDefaultBranch())
11461146
}
1147+
1148+
// GetIssueConfig returns the issue config for a repo
1149+
func GetIssueConfig(ctx *context.APIContext) {
1150+
// swagger:operation GET /repos/{owner}/{repo}/issue_config repository repoGetIssueConfig
1151+
// ---
1152+
// summary: Returns the issue config for a repo
1153+
// produces:
1154+
// - application/json
1155+
// parameters:
1156+
// - name: owner
1157+
// in: path
1158+
// description: owner of the repo
1159+
// type: string
1160+
// required: true
1161+
// - name: repo
1162+
// in: path
1163+
// description: name of the repo
1164+
// type: string
1165+
// required: true
1166+
// responses:
1167+
// "200":
1168+
// "$ref": "#/responses/RepoIssueConfig"
1169+
issueConfig, _ := ctx.IssueConfigFromDefaultBranch()
1170+
ctx.JSON(http.StatusOK, issueConfig)
1171+
}
1172+
1173+
// ValidateIssueConfig returns validation errors for the issue config
1174+
func ValidateIssueConfig(ctx *context.APIContext) {
1175+
// swagger:operation GET /repos/{owner}/{repo}/issue_config/validate repository repoValidateIssueConfig
1176+
// ---
1177+
// summary: Returns the validation information for a issue config
1178+
// produces:
1179+
// - application/json
1180+
// parameters:
1181+
// - name: owner
1182+
// in: path
1183+
// description: owner of the repo
1184+
// type: string
1185+
// required: true
1186+
// - name: repo
1187+
// in: path
1188+
// description: name of the repo
1189+
// type: string
1190+
// required: true
1191+
// responses:
1192+
// "200":
1193+
// "$ref": "#/responses/RepoIssueConfigValidation"
1194+
_, err := ctx.IssueConfigFromDefaultBranch()
1195+
1196+
if err == nil {
1197+
ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: true, Message: ""})
1198+
} else {
1199+
ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: false, Message: err.Error()})
1200+
}
1201+
}

routers/api/v1/swagger/repo.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,17 @@ type swaggerRepoCollaboratorPermission struct {
386386
// in:body
387387
Body api.RepoCollaboratorPermission `json:"body"`
388388
}
389+
390+
// RepoIssueConfig
391+
// swagger:response RepoIssueConfig
392+
type swaggerRepoIssueConfig struct {
393+
// in:body
394+
Body api.IssueConfig `json:"body"`
395+
}
396+
397+
// RepoIssueConfigValidation
398+
// swagger:response RepoIssueConfigValidation
399+
type swaggerRepoIssueConfigValidation struct {
400+
// in:body
401+
Body api.IssueConfigValidation `json:"body"`
402+
}

routers/web/repo/issue.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ func Issues(ctx *context.Context) {
435435
}
436436
ctx.Data["Title"] = ctx.Tr("repo.issues")
437437
ctx.Data["PageIsIssueList"] = true
438-
ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0
438+
ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
439439
}
440440

441441
issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), util.OptionalBoolOf(isPullList))
@@ -848,7 +848,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
848848
func NewIssue(ctx *context.Context) {
849849
ctx.Data["Title"] = ctx.Tr("repo.issues.new")
850850
ctx.Data["PageIsIssueList"] = true
851-
ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0
851+
ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
852852
ctx.Data["RequireTribute"] = true
853853
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
854854
title := ctx.FormString("title")
@@ -946,12 +946,16 @@ func NewIssueChooseTemplate(ctx *context.Context) {
946946
ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true)
947947
}
948948

949-
if len(issueTemplates) == 0 {
949+
if !ctx.HasIssueTemplatesOrContactLinks() {
950950
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters.
951951
ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
952952
return
953953
}
954954

955+
issueConfig, err := ctx.IssueConfigFromDefaultBranch()
956+
ctx.Data["IssueConfig"] = issueConfig
957+
ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here
958+
955959
ctx.Data["milestone"] = ctx.FormInt64("milestone")
956960
ctx.Data["project"] = ctx.FormInt64("project")
957961

@@ -1086,7 +1090,7 @@ func NewIssuePost(ctx *context.Context) {
10861090
form := web.GetForm(ctx).(*forms.CreateIssueForm)
10871091
ctx.Data["Title"] = ctx.Tr("repo.issues.new")
10881092
ctx.Data["PageIsIssueList"] = true
1089-
ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0
1093+
ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
10901094
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
10911095
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
10921096
upload.AddUploadContext(ctx, "comment")
@@ -1280,7 +1284,7 @@ func ViewIssue(ctx *context.Context) {
12801284
return
12811285
}
12821286
ctx.Data["PageIsIssueList"] = true
1283-
ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0
1287+
ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
12841288
}
12851289

12861290
if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) {

routers/web/repo/view.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
348348
if ctx.Repo.TreePath == ".editorconfig" {
349349
_, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
350350
ctx.Data["FileError"] = editorconfigErr
351+
} else if ctx.Repo.IsIssueConfig(ctx.Repo.TreePath) {
352+
_, issueConfigErr := ctx.Repo.GetIssueConfig(ctx.Repo.TreePath, ctx.Repo.Commit)
353+
ctx.Data["FileError"] = issueConfigErr
351354
}
352355

353356
isDisplayingSource := ctx.FormString("display") == "source"

0 commit comments

Comments
 (0)