diff --git a/.forgejo/workflows/testing.yml b/.forgejo/workflows/testing.yml
index dd0b280dc4..a4b54c4da5 100644
--- a/.forgejo/workflows/testing.yml
+++ b/.forgejo/workflows/testing.yml
@@ -8,7 +8,7 @@ on:
       - 'v*/forgejo*'
 
 jobs:
-  lint-backend:
+  backend-checks:
     if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
     runs-on: docker
     container:
@@ -20,26 +20,13 @@ jobs:
           go-version: "1.21"
           check-latest: true
       - run: make deps-backend deps-tools
-      - run: make lint-backend
+      - run: make --always-make -j$(nproc) lint-backend checks-backend # ensure the "go-licenses" make target runs
         env:
           TAGS: bindata sqlite sqlite_unlock_notify
-  checks-backend:
-    if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
-    runs-on: docker
-    container:
-      image: 'docker.io/node:20-bookworm'
-    steps:
-      - uses: https://code.forgejo.org/actions/checkout@v3
-      - uses: https://code.forgejo.org/actions/setup-go@v4
-        with:
-          go-version: "1.21"
-          check-latest: true
-      - run: make deps-backend deps-tools
-      - run: make --always-make checks-backend # ensure the "go-licenses" make target runs
   test-unit:
     if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
     runs-on: docker
-    needs: [lint-backend, checks-backend]
+    needs: [backend-checks]
     container:
       image: 'docker.io/node:20-bookworm'
     services:
@@ -80,7 +67,7 @@ jobs:
   test-mysql:
     if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
     runs-on: docker
-    needs: [lint-backend, checks-backend]
+    needs: [backend-checks]
     container:
       image: 'docker.io/node:20-bookworm'
     services:
@@ -126,7 +113,7 @@ jobs:
   test-pgsql:
     if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
     runs-on: docker
-    needs: [lint-backend, checks-backend]
+    needs: [backend-checks]
     container:
       image: 'docker.io/node:20-bookworm'
     services:
@@ -174,7 +161,7 @@ jobs:
   test-sqlite:
     if: ${{ !startsWith(vars.ROLE, 'forgejo-') }}
     runs-on: docker
-    needs: [lint-backend, checks-backend]
+    needs: [backend-checks]
     container:
       image: 'docker.io/node:20-bookworm'
     steps:
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index b7ac5b4543..08e79e544a 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -1174,11 +1174,7 @@ func GetIssueTemplates(ctx *context.APIContext) {
 	//     "$ref": "#/responses/IssueTemplates"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
-	ret, err := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, "GetTemplatesFromDefaultBranch", err)
-		return
-	}
+	ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
 	ctx.JSON(http.StatusOK, ret)
 }
 
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 62ebb4f382..3e7b099bba 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -77,6 +77,12 @@ var IssueTemplateCandidates = []string{
 	"issue_template.md",
 	"issue_template.yaml",
 	"issue_template.yml",
+	".forgejo/ISSUE_TEMPLATE.md",
+	".forgejo/ISSUE_TEMPLATE.yaml",
+	".forgejo/ISSUE_TEMPLATE.yml",
+	".forgejo/issue_template.md",
+	".forgejo/issue_template.yaml",
+	".forgejo/issue_template.yml",
 	".gitea/ISSUE_TEMPLATE.md",
 	".gitea/ISSUE_TEMPLATE.yaml",
 	".gitea/ISSUE_TEMPLATE.yml",
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 52c4cf8685..7830c17ced 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -65,6 +65,12 @@ var pullRequestTemplateCandidates = []string{
 	"pull_request_template.md",
 	"pull_request_template.yaml",
 	"pull_request_template.yml",
+	".forgejo/PULL_REQUEST_TEMPLATE.md",
+	".forgejo/PULL_REQUEST_TEMPLATE.yaml",
+	".forgejo/PULL_REQUEST_TEMPLATE.yml",
+	".forgejo/pull_request_template.md",
+	".forgejo/pull_request_template.yaml",
+	".forgejo/pull_request_template.yml",
 	".gitea/PULL_REQUEST_TEMPLATE.md",
 	".gitea/PULL_REQUEST_TEMPLATE.yaml",
 	".gitea/PULL_REQUEST_TEMPLATE.yml",
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 1dda3a05bc..9dc708bca4 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -94,6 +94,10 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
 				if entry.Name() == "docs" || docsEntries[0] == nil {
 					docsEntries[0] = entry
 				}
+			case ".forgejo":
+				if entry.Name() == ".forgejo" || docsEntries[1] == nil {
+					docsEntries[1] = entry
+				}
 			case ".gitea":
 				if entry.Name() == ".gitea" || docsEntries[1] == nil {
 					docsEntries[1] = entry
diff --git a/services/issue/template.go b/services/issue/template.go
index b6ae077987..47633e5d85 100644
--- a/services/issue/template.go
+++ b/services/issue/template.go
@@ -23,6 +23,8 @@ import (
 var templateDirCandidates = []string{
 	"ISSUE_TEMPLATE",
 	"issue_template",
+	".forgejo/ISSUE_TEMPLATE",
+	".forgejo/issue_template",
 	".gitea/ISSUE_TEMPLATE",
 	".gitea/issue_template",
 	".github/ISSUE_TEMPLATE",
@@ -32,6 +34,8 @@ var templateDirCandidates = []string{
 }
 
 var templateConfigCandidates = []string{
+	".forgejo/ISSUE_TEMPLATE/config",
+	".forgejo/issue_template/config",
 	".gitea/ISSUE_TEMPLATE/config",
 	".gitea/issue_template/config",
 	".github/ISSUE_TEMPLATE/config",
diff --git a/services/pull/merge.go b/services/pull/merge.go
index 91b110351a..718e964014 100644
--- a/services/pull/merge.go
+++ b/services/pull/merge.go
@@ -55,12 +55,18 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue
 	}
 
 	if mergeStyle != "" {
-		templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
 		commit, err := baseGitRepo.GetBranchCommit(pr.BaseRepo.DefaultBranch)
 		if err != nil {
 			return "", "", err
 		}
-		templateContent, err := commit.GetFileContent(templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize)
+
+		templateFilepathForgejo := fmt.Sprintf(".forgejo/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
+		templateFilepathGitea := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
+
+		templateContent, err := commit.GetFileContent(templateFilepathForgejo, setting.Repository.PullRequest.DefaultMergeMessageSize)
+		if _, ok := err.(git.ErrNotExist); ok {
+			templateContent, err = commit.GetFileContent(templateFilepathGitea, setting.Repository.PullRequest.DefaultMergeMessageSize)
+		}
 		if err != nil {
 			if !git.IsErrNotExist(err) {
 				return "", "", err
diff --git a/tests/integration/api_issue_config_test.go b/tests/integration/api_issue_config_test.go
index b9125438b6..d37036381e 100644
--- a/tests/integration/api_issue_config_test.go
+++ b/tests/integration/api_issue_config_test.go
@@ -1,4 +1,5 @@
 // Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
 // SPDX-License-Identifier: MIT
 
 package integration
@@ -18,14 +19,18 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) {
+func createIssueConfigInDirectory(t *testing.T, user *user_model.User, repo *repo_model.Repository, dir string, issueConfig map[string]any) {
 	config, err := yaml.Marshal(issueConfig)
 	assert.NoError(t, err)
 
-	err = createOrReplaceFileInBranch(user, repo, ".gitea/ISSUE_TEMPLATE/config.yaml", repo.DefaultBranch, string(config))
+	err = createOrReplaceFileInBranch(user, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch, string(config))
 	assert.NoError(t, err)
 }
 
+func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) {
+	createIssueConfigInDirectory(t, user, repo, ".gitea", issueConfig)
+}
+
 func getIssueConfig(t *testing.T, owner, repo string) api.IssueConfig {
 	urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config", owner, repo)
 	req := NewRequest(t, "GET", urlStr)
@@ -44,6 +49,8 @@ func TestAPIRepoGetIssueConfig(t *testing.T) {
 	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
 
 	t.Run("Default", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
 		issueConfig := getIssueConfig(t, owner.Name, repo.Name)
 
 		assert.True(t, issueConfig.BlankIssuesEnabled)
@@ -51,6 +58,8 @@ func TestAPIRepoGetIssueConfig(t *testing.T) {
 	})
 
 	t.Run("DisableBlankIssues", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
 		config := make(map[string]any)
 		config["blank_issues_enabled"] = false
 
@@ -63,6 +72,8 @@ func TestAPIRepoGetIssueConfig(t *testing.T) {
 	})
 
 	t.Run("ContactLinks", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
 		contactLink := make(map[string]string)
 		contactLink["name"] = "TestName"
 		contactLink["url"] = "https://example.com"
@@ -84,6 +95,8 @@ func TestAPIRepoGetIssueConfig(t *testing.T) {
 	})
 
 	t.Run("Full", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
 		contactLink := make(map[string]string)
 		contactLink["name"] = "TestName"
 		contactLink["url"] = "https://example.com"
@@ -113,6 +126,8 @@ func TestAPIRepoIssueConfigPaths(t *testing.T) {
 	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
 
 	templateConfigCandidates := []string{
+		".forgejo/ISSUE_TEMPLATE/config",
+		".forgejo/issue_template/config",
 		".gitea/ISSUE_TEMPLATE/config",
 		".gitea/issue_template/config",
 		".github/ISSUE_TEMPLATE/config",
@@ -123,6 +138,8 @@ func TestAPIRepoIssueConfigPaths(t *testing.T) {
 		for _, extension := range []string{".yaml", ".yml"} {
 			fullPath := canidate + extension
 			t.Run(fullPath, func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+
 				configMap := make(map[string]any)
 				configMap["blank_issues_enabled"] = false
 
@@ -153,6 +170,8 @@ func TestAPIRepoValidateIssueConfig(t *testing.T) {
 	urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config/validate", owner.Name, repo.Name)
 
 	t.Run("Valid", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
 		req := NewRequest(t, "GET", urlStr)
 		resp := MakeRequest(t, req, http.StatusOK)
 
@@ -164,18 +183,28 @@ func TestAPIRepoValidateIssueConfig(t *testing.T) {
 	})
 
 	t.Run("Invalid", func(t *testing.T) {
-		config := make(map[string]any)
-		config["blank_issues_enabled"] = "Test"
+		dirs := []string{".gitea", ".forgejo"}
+		for _, dir := range dirs {
+			t.Run(dir, func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+				defer func() {
+					deleteFileInBranch(owner, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch)
+				}()
 
-		createIssueConfig(t, owner, repo, config)
+				config := make(map[string]any)
+				config["blank_issues_enabled"] = "Test"
 
-		req := NewRequest(t, "GET", urlStr)
-		resp := MakeRequest(t, req, http.StatusOK)
+				createIssueConfigInDirectory(t, owner, repo, dir, config)
 
-		var issueConfigValidation api.IssueConfigValidation
-		DecodeJSON(t, resp, &issueConfigValidation)
+				req := NewRequest(t, "GET", urlStr)
+				resp := MakeRequest(t, req, http.StatusOK)
 
-		assert.False(t, issueConfigValidation.Valid)
-		assert.NotEmpty(t, issueConfigValidation.Message)
+				var issueConfigValidation api.IssueConfigValidation
+				DecodeJSON(t, resp, &issueConfigValidation)
+
+				assert.False(t, issueConfigValidation.Valid)
+				assert.NotEmpty(t, issueConfigValidation.Message)
+			})
+		}
 	})
 }
diff --git a/tests/integration/api_issue_templates_test.go b/tests/integration/api_issue_templates_test.go
new file mode 100644
index 0000000000..15c2dd422b
--- /dev/null
+++ b/tests/integration/api_issue_templates_test.go
@@ -0,0 +1,114 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIIssueTemplateList(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+		user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+		t.Run("no templates", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+			resp := MakeRequest(t, req, http.StatusOK)
+			var issueTemplates []*api.IssueTemplate
+			DecodeJSON(t, resp, &issueTemplates)
+			assert.Empty(t, issueTemplates)
+		})
+
+		t.Run("existing template", func(t *testing.T) {
+			templateCandidates := []string{
+				".forgejo/ISSUE_TEMPLATE/test.md",
+				".forgejo/issue_template/test.md",
+				".gitea/ISSUE_TEMPLATE/test.md",
+				".gitea/issue_template/test.md",
+				".github/ISSUE_TEMPLATE/test.md",
+				".github/issue_template/test.md",
+			}
+
+			for _, template := range templateCandidates {
+				t.Run(template, func(t *testing.T) {
+					defer tests.PrintCurrentTest(t)()
+					defer func() {
+						deleteFileInBranch(user, repo, template, repo.DefaultBranch)
+					}()
+
+					err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch,
+						`---
+name: 'Template Name'
+about: 'This template is for testing!'
+title: '[TEST] '
+ref: 'main'
+---
+
+This is the template!`)
+					assert.NoError(t, err)
+
+					req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+					resp := MakeRequest(t, req, http.StatusOK)
+					var issueTemplates []*api.IssueTemplate
+					DecodeJSON(t, resp, &issueTemplates)
+					assert.Len(t, issueTemplates, 1)
+					assert.Equal(t, "Template Name", issueTemplates[0].Name)
+					assert.Equal(t, "This template is for testing!", issueTemplates[0].About)
+					assert.Equal(t, "refs/heads/main", issueTemplates[0].Ref)
+					assert.Equal(t, template, issueTemplates[0].FileName)
+				})
+			}
+		})
+
+		t.Run("multiple templates", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+			templatePriority := []string{
+				".forgejo/issue_template/test.md",
+				".gitea/issue_template/test.md",
+				".github/issue_template/test.md",
+			}
+			defer func() {
+				for _, template := range templatePriority {
+					deleteFileInBranch(user, repo, template, repo.DefaultBranch)
+				}
+			}()
+
+			for _, template := range templatePriority {
+				err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch,
+					`---
+name: 'Template Name'
+about: 'This template is for testing!'
+title: '[TEST] '
+ref: 'main'
+---
+
+This is the template!`)
+				assert.NoError(t, err)
+			}
+
+			req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+			resp := MakeRequest(t, req, http.StatusOK)
+			var issueTemplates []*api.IssueTemplate
+			DecodeJSON(t, resp, &issueTemplates)
+
+			// If templates have the same filename and content, but in different
+			// directories, they count as different templates, and all are
+			// considered.
+			assert.Len(t, issueTemplates, 3)
+		})
+	})
+}
diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go
index 0aeecd5880..cca0a2e68c 100644
--- a/tests/integration/pull_create_test.go
+++ b/tests/integration/pull_create_test.go
@@ -96,6 +96,105 @@ func TestPullCreate(t *testing.T) {
 	})
 }
 
+func TestPullCreateWithPullTemplate(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+		forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+		templateCandidates := []string{
+			".forgejo/PULL_REQUEST_TEMPLATE.md",
+			".forgejo/pull_request_template.md",
+			".gitea/PULL_REQUEST_TEMPLATE.md",
+			".gitea/pull_request_template.md",
+			".github/PULL_REQUEST_TEMPLATE.md",
+			".github/pull_request_template.md",
+		}
+
+		createBaseRepo := func(t *testing.T, templateFiles []string, message string) (*repo_model.Repository, func()) {
+			t.Helper()
+
+			changeOps := make([]*files_service.ChangeRepoFile, len(templateFiles))
+			for i, template := range templateFiles {
+				changeOps[i] = &files_service.ChangeRepoFile{
+					Operation:     "create",
+					TreePath:      template,
+					ContentReader: strings.NewReader(message + " " + template),
+				}
+			}
+
+			repo, _, deferrer := CreateDeclarativeRepo(t, baseUser, "", nil, nil, changeOps)
+
+			return repo, deferrer
+		}
+
+		testPullPreview := func(t *testing.T, session *TestSession, user, repo, message string) {
+			t.Helper()
+
+			req := NewRequest(t, "GET", path.Join(user, repo))
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			// Click the PR button to create a pull
+			htmlDoc := NewHTMLParser(t, resp.Body)
+			link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href")
+			assert.True(t, exists, "The template has changed")
+
+			// Load the pull request preview
+			req = NewRequest(t, "GET", link)
+			resp = session.MakeRequest(t, req, http.StatusOK)
+
+			// Check that the message from the template is present.
+			htmlDoc = NewHTMLParser(t, resp.Body)
+			pullRequestMessage := htmlDoc.doc.Find("textarea[placeholder*='comment']").Text()
+			assert.Equal(t, message, pullRequestMessage)
+		}
+
+		for i, template := range templateCandidates {
+			t.Run(template, func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+
+				// Create the base repository, with the pull request template added.
+				message := fmt.Sprintf("TestPullCreateWithPullTemplate/%s", template)
+				baseRepo, deferrer := createBaseRepo(t, []string{template}, message)
+				defer deferrer()
+
+				// Fork the repository
+				session := loginUser(t, forkUser.Name)
+				testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name)
+				forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name})
+
+				// Apply a change to the fork
+				err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, fmt.Sprintf("Hello, World (%d)\n", i))
+				assert.NoError(t, err)
+
+				testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" "+template)
+			})
+		}
+
+		t.Run("multiple template options", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			// Create the base repository, with the pull request template added.
+			message := "TestPullCreateWithPullTemplate/multiple"
+			baseRepo, deferrer := createBaseRepo(t, templateCandidates, message)
+			defer deferrer()
+
+			// Fork the repository
+			session := loginUser(t, forkUser.Name)
+			testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name)
+			forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name})
+
+			// Apply a change to the fork
+			err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, "Hello, World (%d)\n")
+			assert.NoError(t, err)
+
+			// Unlike issues, where all candidates are considered and shown, for
+			// pull request, there's a priority: if there are multiple
+			// templates, only the highest priority one is used.
+			testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" .forgejo/PULL_REQUEST_TEMPLATE.md")
+		})
+	})
+}
+
 func TestPullCreate_TitleEscape(t *testing.T) {
 	onGiteaRun(t, func(t *testing.T, u *url.URL) {
 		session := loginUser(t, "user1")