diff --git a/modules/context/context.go b/modules/context/context.go
index 3e1b48dcd..9ba1985f3 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -36,19 +36,20 @@ type Render interface {
 
 // Context represents context of a request.
 type Context struct {
-	Resp     ResponseWriter
-	Req      *http.Request
+	Resp   ResponseWriter
+	Req    *http.Request
+	Render Render
+
 	Data     middleware.ContextData // data used by MVC templates
 	PageData map[string]any         // data used by JavaScript modules in one page, it's `window.config.pageData`
-	Render   Render
-	Locale   translation.Locale
-	Cache    cache.Cache
-	Csrf     CSRFProtector
-	Flash    *middleware.Flash
-	Session  session.Store
 
-	Link        string // current request URL
-	EscapedLink string
+	Locale  translation.Locale
+	Cache   cache.Cache
+	Csrf    CSRFProtector
+	Flash   *middleware.Flash
+	Session session.Store
+
+	Link        string // current request URL (without query string)
 	Doer        *user_model.User
 	IsSigned    bool
 	IsBasicAuth bool
diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go
index 5cb4ea0ac..9ce67a529 100644
--- a/modules/context/context_cookie.go
+++ b/modules/context/context_cookie.go
@@ -6,7 +6,6 @@ package context
 import (
 	"encoding/hex"
 	"net/http"
-	"strconv"
 	"strings"
 
 	"code.gitea.io/gitea/modules/setting"
@@ -85,21 +84,3 @@ func (ctx *Context) CookieEncrypt(secret, value string) string {
 
 	return hex.EncodeToString(text)
 }
-
-// GetCookieInt returns cookie result in int type.
-func (ctx *Context) GetCookieInt(name string) int {
-	r, _ := strconv.Atoi(ctx.GetSiteCookie(name))
-	return r
-}
-
-// GetCookieInt64 returns cookie result in int64 type.
-func (ctx *Context) GetCookieInt64(name string) int64 {
-	r, _ := strconv.ParseInt(ctx.GetSiteCookie(name), 10, 64)
-	return r
-}
-
-// GetCookieFloat64 returns cookie result in float64 type.
-func (ctx *Context) GetCookieFloat64(name string) float64 {
-	v, _ := strconv.ParseFloat(ctx.GetSiteCookie(name), 64)
-	return v
-}
diff --git a/modules/context/context_model.go b/modules/context/context_model.go
index 5ba98f7e0..4f70aac51 100644
--- a/modules/context/context_model.go
+++ b/modules/context/context_model.go
@@ -4,14 +4,7 @@
 package context
 
 import (
-	"path"
-	"strings"
-
 	"code.gitea.io/gitea/models/unit"
-	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/issue/template"
-	"code.gitea.io/gitea/modules/log"
-	api "code.gitea.io/gitea/modules/structs"
 )
 
 // IsUserSiteAdmin returns true if current user is a site admin
@@ -19,11 +12,6 @@ func (ctx *Context) IsUserSiteAdmin() bool {
 	return ctx.IsSigned && ctx.Doer.IsAdmin
 }
 
-// IsUserRepoOwner returns true if current user owns current repo
-func (ctx *Context) IsUserRepoOwner() bool {
-	return ctx.Repo.IsOwner()
-}
-
 // IsUserRepoAdmin returns true if current user is admin in current repo
 func (ctx *Context) IsUserRepoAdmin() bool {
 	return ctx.Repo.IsAdmin()
@@ -39,100 +27,3 @@ func (ctx *Context) IsUserRepoWriter(unitTypes []unit.Type) bool {
 
 	return false
 }
-
-// IsUserRepoReaderSpecific returns true if current user can read current repo's specific part
-func (ctx *Context) IsUserRepoReaderSpecific(unitType unit.Type) bool {
-	return ctx.Repo.CanRead(unitType)
-}
-
-// IsUserRepoReaderAny returns true if current user can read any part of current repo
-func (ctx *Context) IsUserRepoReaderAny() bool {
-	return ctx.Repo.HasAccess()
-}
-
-// IssueTemplatesFromDefaultBranch checks for valid issue templates in the repo's default branch,
-func (ctx *Context) IssueTemplatesFromDefaultBranch() []*api.IssueTemplate {
-	ret, _ := ctx.IssueTemplatesErrorsFromDefaultBranch()
-	return ret
-}
-
-// IssueTemplatesErrorsFromDefaultBranch checks for issue templates in the repo's default branch,
-// returns valid templates and the errors of invalid template files.
-func (ctx *Context) IssueTemplatesErrorsFromDefaultBranch() ([]*api.IssueTemplate, map[string]error) {
-	var issueTemplates []*api.IssueTemplate
-
-	if ctx.Repo.Repository.IsEmpty {
-		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, nil
-		}
-	}
-
-	invalidFiles := map[string]error{}
-	for _, dirName := range IssueTemplateDirCandidates {
-		tree, err := ctx.Repo.Commit.SubTree(dirName)
-		if err != nil {
-			log.Debug("get sub tree of %s: %v", dirName, err)
-			continue
-		}
-		entries, err := tree.ListEntries()
-		if err != nil {
-			log.Debug("list entries in %s: %v", dirName, err)
-			return issueTemplates, nil
-		}
-		for _, entry := range entries {
-			if !template.CouldBe(entry.Name()) {
-				continue
-			}
-			fullName := path.Join(dirName, entry.Name())
-			if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil {
-				invalidFiles[fullName] = err
-			} else {
-				if !strings.HasPrefix(it.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
-					it.Ref = git.BranchPrefix + it.Ref
-				}
-				issueTemplates = append(issueTemplates, it)
-			}
-		}
-	}
-	return issueTemplates, invalidFiles
-}
-
-// IssueConfigFromDefaultBranch returns the issue config for this repo.
-// It never returns a nil config.
-func (ctx *Context) IssueConfigFromDefaultBranch() (api.IssueConfig, error) {
-	if ctx.Repo.Repository.IsEmpty {
-		return GetDefaultIssueConfig(), nil
-	}
-
-	commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
-	if err != nil {
-		return GetDefaultIssueConfig(), err
-	}
-
-	for _, configName := range IssueConfigCandidates {
-		if _, err := commit.GetTreeEntryByPath(configName + ".yaml"); err == nil {
-			return ctx.Repo.GetIssueConfig(configName+".yaml", commit)
-		}
-
-		if _, err := commit.GetTreeEntryByPath(configName + ".yml"); err == nil {
-			return ctx.Repo.GetIssueConfig(configName+".yml", commit)
-		}
-	}
-
-	return GetDefaultIssueConfig(), nil
-}
-
-func (ctx *Context) HasIssueTemplatesOrContactLinks() bool {
-	if len(ctx.IssueTemplatesFromDefaultBranch()) > 0 {
-		return true
-	}
-
-	issueConfig, _ := ctx.IssueConfigFromDefaultBranch()
-	return len(issueConfig.ContactLinks) > 0
-}
diff --git a/modules/context/repo.go b/modules/context/repo.go
index b33341c24..84e07ab42 100644
--- a/modules/context/repo.go
+++ b/modules/context/repo.go
@@ -8,7 +8,6 @@ import (
 	"context"
 	"fmt"
 	"html"
-	"io"
 	"net/http"
 	"net/url"
 	"path"
@@ -28,33 +27,12 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
-	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
 	asymkey_service "code.gitea.io/gitea/services/asymkey"
 
 	"github.com/editorconfig/editorconfig-core-go/v2"
-	"gopkg.in/yaml.v3"
 )
 
-// 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",
-}
-
-var IssueConfigCandidates = []string{
-	".gitea/ISSUE_TEMPLATE/config",
-	".gitea/issue_template/config",
-	".github/ISSUE_TEMPLATE/config",
-	".github/issue_template/config",
-}
-
 // PullRequest contains information to make a pull request
 type PullRequest struct {
 	BaseRepo       *repo_model.Repository
@@ -1061,74 +1039,3 @@ func UnitTypes() func(ctx *Context) {
 		ctx.Data["UnitTypeActions"] = unit_model.TypeActions
 	}
 }
-
-func GetDefaultIssueConfig() api.IssueConfig {
-	return api.IssueConfig{
-		BlankIssuesEnabled: true,
-		ContactLinks:       make([]api.IssueConfigContactLink, 0),
-	}
-}
-
-// GetIssueConfig loads the given issue config file.
-// It never returns a nil config.
-func (r *Repository) GetIssueConfig(path string, commit *git.Commit) (api.IssueConfig, error) {
-	if r.GitRepo == nil {
-		return GetDefaultIssueConfig(), nil
-	}
-
-	var err error
-
-	treeEntry, err := commit.GetTreeEntryByPath(path)
-	if err != nil {
-		return GetDefaultIssueConfig(), err
-	}
-
-	reader, err := treeEntry.Blob().DataAsync()
-	if err != nil {
-		log.Debug("DataAsync: %v", err)
-		return GetDefaultIssueConfig(), nil
-	}
-
-	defer reader.Close()
-
-	configContent, err := io.ReadAll(reader)
-	if err != nil {
-		return GetDefaultIssueConfig(), err
-	}
-
-	issueConfig := api.IssueConfig{}
-	if err := yaml.Unmarshal(configContent, &issueConfig); err != nil {
-		return GetDefaultIssueConfig(), err
-	}
-
-	for pos, link := range issueConfig.ContactLinks {
-		if link.Name == "" {
-			return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing name key", pos+1)
-		}
-
-		if link.URL == "" {
-			return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing url key", pos+1)
-		}
-
-		if link.About == "" {
-			return GetDefaultIssueConfig(), fmt.Errorf("contact_link at position %d is missing about key", pos+1)
-		}
-
-		_, err = url.ParseRequestURI(link.URL)
-		if err != nil {
-			return GetDefaultIssueConfig(), fmt.Errorf("%s is not a valid URL", link.URL)
-		}
-	}
-
-	return issueConfig, nil
-}
-
-// IsIssueConfig returns if the given path is a issue config file.
-func (r *Repository) IsIssueConfig(path string) bool {
-	for _, configName := range IssueConfigCandidates {
-		if path == configName+".yaml" || path == configName+".yml" {
-			return true
-		}
-	}
-	return false
-}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 9a733b832..a67a5420a 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -316,7 +316,7 @@ func reqSiteAdmin() func(ctx *context.APIContext) {
 // reqOwner user should be the owner of the repo or site admin.
 func reqOwner() func(ctx *context.APIContext) {
 	return func(ctx *context.APIContext) {
-		if !ctx.IsUserRepoOwner() && !ctx.IsUserSiteAdmin() {
+		if !ctx.Repo.IsOwner() && !ctx.IsUserSiteAdmin() {
 			ctx.Error(http.StatusForbidden, "reqOwner", "user should be the owner of the repo")
 			return
 		}
@@ -355,7 +355,7 @@ func reqRepoBranchWriter(ctx *context.APIContext) {
 // reqRepoReader user should have specific read permission or be a repo admin or a site admin
 func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) {
 	return func(ctx *context.APIContext) {
-		if !ctx.IsUserRepoReaderSpecific(unitType) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
+		if !ctx.Repo.CanRead(unitType) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
 			ctx.Error(http.StatusForbidden, "reqRepoReader", "user should have specific read permission or be a repo admin or a site admin")
 			return
 		}
@@ -365,7 +365,7 @@ func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) {
 // reqAnyRepoReader user should have any permission to read repository or permissions of site admin
 func reqAnyRepoReader() func(ctx *context.APIContext) {
 	return func(ctx *context.APIContext) {
-		if !ctx.IsUserRepoReaderAny() && !ctx.IsUserSiteAdmin() {
+		if !ctx.Repo.HasAccess() && !ctx.IsUserSiteAdmin() {
 			ctx.Error(http.StatusForbidden, "reqAnyRepoReader", "user should have any permission to read repository or permissions of site admin")
 			return
 		}
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index 480ca397d..114b93534 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -30,6 +30,7 @@ import (
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/convert"
+	"code.gitea.io/gitea/services/issue"
 	repo_service "code.gitea.io/gitea/services/repository"
 )
 
@@ -1144,8 +1145,12 @@ func GetIssueTemplates(ctx *context.APIContext) {
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/IssueTemplates"
-
-	ctx.JSON(http.StatusOK, ctx.IssueTemplatesFromDefaultBranch())
+	ret, err := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "GetTemplatesFromDefaultBranch", err)
+		return
+	}
+	ctx.JSON(http.StatusOK, ret)
 }
 
 // GetIssueConfig returns the issue config for a repo
@@ -1169,7 +1174,7 @@ func GetIssueConfig(ctx *context.APIContext) {
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/RepoIssueConfig"
-	issueConfig, _ := ctx.IssueConfigFromDefaultBranch()
+	issueConfig, _ := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.JSON(http.StatusOK, issueConfig)
 }
 
@@ -1194,7 +1199,7 @@ func ValidateIssueConfig(ctx *context.APIContext) {
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/RepoIssueConfigValidation"
-	_, err := ctx.IssueConfigFromDefaultBranch()
+	_, err := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 
 	if err == nil {
 		ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: true, Message: ""})
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 4efac5c38..c2f30a01f 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -431,7 +431,7 @@ func Issues(ctx *context.Context) {
 		}
 		ctx.Data["Title"] = ctx.Tr("repo.issues")
 		ctx.Data["PageIsIssueList"] = true
-		ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
+		ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	}
 
 	issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), util.OptionalBoolOf(isPullList))
@@ -862,7 +862,7 @@ 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"] = ctx.HasIssueTemplatesOrContactLinks()
+	ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
 	title := ctx.FormString("title")
 	ctx.Data["TitleQuery"] = title
@@ -904,7 +904,7 @@ func NewIssue(ctx *context.Context) {
 
 	RetrieveRepoMetas(ctx, ctx.Repo.Repository, false)
 
-	_, templateErrs := ctx.IssueTemplatesErrorsFromDefaultBranch()
+	_, templateErrs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	if errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates); len(errs) > 0 {
 		for k, v := range errs {
 			templateErrs[k] = v
@@ -952,20 +952,20 @@ func NewIssueChooseTemplate(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.issues.new")
 	ctx.Data["PageIsIssueList"] = true
 
-	issueTemplates, errs := ctx.IssueTemplatesErrorsFromDefaultBranch()
+	issueTemplates, errs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.Data["IssueTemplates"] = issueTemplates
 
 	if len(errs) > 0 {
 		ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true)
 	}
 
-	if !ctx.HasIssueTemplatesOrContactLinks() {
+	if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) {
 		// 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.
 		ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
 		return
 	}
 
-	issueConfig, err := ctx.IssueConfigFromDefaultBranch()
+	issueConfig, err := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.Data["IssueConfig"] = issueConfig
 	ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here
 
@@ -1103,7 +1103,7 @@ 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"] = ctx.HasIssueTemplatesOrContactLinks()
+	ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
 	upload.AddUploadContext(ctx, "comment")
@@ -1297,7 +1297,7 @@ func ViewIssue(ctx *context.Context) {
 			return
 		}
 		ctx.Data["PageIsIssueList"] = true
-		ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
+		ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	}
 
 	if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) {
diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go
index d712df100..4b33fbcb1 100644
--- a/routers/web/repo/milestone.go
+++ b/routers/web/repo/milestone.go
@@ -20,6 +20,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/forms"
+	"code.gitea.io/gitea/services/issue"
 
 	"xorm.io/builder"
 )
@@ -289,7 +290,9 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
 	ctx.Data["Milestone"] = milestone
 
 	issues(ctx, milestoneID, 0, util.OptionalBoolNone)
-	ctx.Data["NewIssueChooseTemplate"] = len(ctx.IssueTemplatesFromDefaultBranch()) > 0
+
+	ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
+	ctx.Data["NewIssueChooseTemplate"] = len(ret) > 0
 
 	ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false)
 	ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true)
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 2bf293cbd..2fd893f91 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -40,6 +40,7 @@ import (
 	"code.gitea.io/gitea/modules/typesniffer"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/routers/web/feed"
+	issue_service "code.gitea.io/gitea/services/issue"
 
 	"github.com/nektos/act/pkg/model"
 )
@@ -346,8 +347,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 		if editorconfigErr != nil {
 			ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error())
 		}
-	} else if ctx.Repo.IsIssueConfig(ctx.Repo.TreePath) {
-		_, issueConfigErr := ctx.Repo.GetIssueConfig(ctx.Repo.TreePath, ctx.Repo.Commit)
+	} else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) {
+		_, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit)
 		if issueConfigErr != nil {
 			ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error())
 		}
diff --git a/services/issue/template.go b/services/issue/template.go
new file mode 100644
index 000000000..4f1e3d93a
--- /dev/null
+++ b/services/issue/template.go
@@ -0,0 +1,189 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issue
+
+import (
+	"fmt"
+	"io"
+	"net/url"
+	"path"
+	"strings"
+
+	"code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/issue/template"
+	"code.gitea.io/gitea/modules/log"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"gopkg.in/yaml.v3"
+)
+
+// templateDirCandidates issue templates directory
+var templateDirCandidates = []string{
+	"ISSUE_TEMPLATE",
+	"issue_template",
+	".gitea/ISSUE_TEMPLATE",
+	".gitea/issue_template",
+	".github/ISSUE_TEMPLATE",
+	".github/issue_template",
+	".gitlab/ISSUE_TEMPLATE",
+	".gitlab/issue_template",
+}
+
+var templateConfigCandidates = []string{
+	".gitea/ISSUE_TEMPLATE/config",
+	".gitea/issue_template/config",
+	".github/ISSUE_TEMPLATE/config",
+	".github/issue_template/config",
+}
+
+func GetDefaultTemplateConfig() api.IssueConfig {
+	return api.IssueConfig{
+		BlankIssuesEnabled: true,
+		ContactLinks:       make([]api.IssueConfigContactLink, 0),
+	}
+}
+
+// GetTemplateConfig loads the given issue config file.
+// It never returns a nil config.
+func GetTemplateConfig(gitRepo *git.Repository, path string, commit *git.Commit) (api.IssueConfig, error) {
+	if gitRepo == nil {
+		return GetDefaultTemplateConfig(), nil
+	}
+
+	var err error
+
+	treeEntry, err := commit.GetTreeEntryByPath(path)
+	if err != nil {
+		return GetDefaultTemplateConfig(), err
+	}
+
+	reader, err := treeEntry.Blob().DataAsync()
+	if err != nil {
+		log.Debug("DataAsync: %v", err)
+		return GetDefaultTemplateConfig(), nil
+	}
+
+	defer reader.Close()
+
+	configContent, err := io.ReadAll(reader)
+	if err != nil {
+		return GetDefaultTemplateConfig(), err
+	}
+
+	issueConfig := api.IssueConfig{}
+	if err := yaml.Unmarshal(configContent, &issueConfig); err != nil {
+		return GetDefaultTemplateConfig(), err
+	}
+
+	for pos, link := range issueConfig.ContactLinks {
+		if link.Name == "" {
+			return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing name key", pos+1)
+		}
+
+		if link.URL == "" {
+			return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing url key", pos+1)
+		}
+
+		if link.About == "" {
+			return GetDefaultTemplateConfig(), fmt.Errorf("contact_link at position %d is missing about key", pos+1)
+		}
+
+		_, err = url.ParseRequestURI(link.URL)
+		if err != nil {
+			return GetDefaultTemplateConfig(), fmt.Errorf("%s is not a valid URL", link.URL)
+		}
+	}
+
+	return issueConfig, nil
+}
+
+// IsTemplateConfig returns if the given path is a issue config file.
+func IsTemplateConfig(path string) bool {
+	for _, configName := range templateConfigCandidates {
+		if path == configName+".yaml" || path == configName+".yml" {
+			return true
+		}
+	}
+	return false
+}
+
+// GetTemplatesFromDefaultBranch checks for issue templates in the repo's default branch,
+// returns valid templates and the errors of invalid template files.
+func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) ([]*api.IssueTemplate, map[string]error) {
+	var issueTemplates []*api.IssueTemplate
+
+	if repo.IsEmpty {
+		return issueTemplates, nil
+	}
+
+	commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
+	if err != nil {
+		return issueTemplates, nil
+	}
+
+	invalidFiles := map[string]error{}
+	for _, dirName := range templateDirCandidates {
+		tree, err := commit.SubTree(dirName)
+		if err != nil {
+			log.Debug("get sub tree of %s: %v", dirName, err)
+			continue
+		}
+		entries, err := tree.ListEntries()
+		if err != nil {
+			log.Debug("list entries in %s: %v", dirName, err)
+			return issueTemplates, nil
+		}
+		for _, entry := range entries {
+			if !template.CouldBe(entry.Name()) {
+				continue
+			}
+			fullName := path.Join(dirName, entry.Name())
+			if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil {
+				invalidFiles[fullName] = err
+			} else {
+				if !strings.HasPrefix(it.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
+					it.Ref = git.BranchPrefix + it.Ref
+				}
+				issueTemplates = append(issueTemplates, it)
+			}
+		}
+	}
+	return issueTemplates, invalidFiles
+}
+
+// GetTemplateConfigFromDefaultBranch returns the issue config for this repo.
+// It never returns a nil config.
+func GetTemplateConfigFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) (api.IssueConfig, error) {
+	if repo.IsEmpty {
+		return GetDefaultTemplateConfig(), nil
+	}
+
+	commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
+	if err != nil {
+		return GetDefaultTemplateConfig(), err
+	}
+
+	for _, configName := range templateConfigCandidates {
+		if _, err := commit.GetTreeEntryByPath(configName + ".yaml"); err == nil {
+			return GetTemplateConfig(gitRepo, configName+".yaml", commit)
+		}
+
+		if _, err := commit.GetTreeEntryByPath(configName + ".yml"); err == nil {
+			return GetTemplateConfig(gitRepo, configName+".yml", commit)
+		}
+	}
+
+	return GetDefaultTemplateConfig(), nil
+}
+
+func HasTemplatesOrContactLinks(repo *repo.Repository, gitRepo *git.Repository) bool {
+	ret, _ := GetTemplatesFromDefaultBranch(repo, gitRepo)
+	if len(ret) > 0 {
+		return true
+	}
+
+	issueConfig, _ := GetTemplateConfigFromDefaultBranch(repo, gitRepo)
+	return len(issueConfig.ContactLinks) > 0
+}