From 781c6df40fcd8c8a112d048b4beb079d0b9a7f2b Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 24 Dec 2024 09:54:19 +0800
Subject: [PATCH] Add sub issue list support (#32940)

Just like GitHub, show issue icon/title when the issue number is in a list
---
 models/unittest/testdb.go                     |  2 +-
 modules/markup/html_commit.go                 | 19 ++++
 modules/markup/html_issue.go                  | 90 +++++++++++--------
 modules/markup/html_issue_test.go             | 72 +++++++++++++++
 modules/markup/render_helper.go               |  1 +
 modules/references/references.go              | 22 +++--
 modules/references/references_test.go         |  5 +-
 modules/svg/processor.go                      | 37 ++++----
 routers/init.go                               |  2 +-
 services/context/context_response.go          |  2 +-
 services/markup/main_test.go                  |  2 +-
 .../{processorhelper.go => renderhelper.go}   |  3 +-
 ...preview.go => renderhelper_codepreview.go} |  3 +-
 ...st.go => renderhelper_codepreview_test.go} |  5 +-
 .../markup/renderhelper_issueicontitle.go     | 66 ++++++++++++++
 .../renderhelper_issueicontitle_test.go       | 49 ++++++++++
 ...r_test.go => renderhelper_mention_test.go} | 18 ++--
 templates/shared/issueicon.tmpl               | 48 +++++-----
 tests/test_utils.go                           |  2 +-
 19 files changed, 332 insertions(+), 116 deletions(-)
 create mode 100644 modules/markup/html_issue_test.go
 rename services/markup/{processorhelper.go => renderhelper.go} (88%)
 rename services/markup/{processorhelper_codepreview.go => renderhelper_codepreview.go} (97%)
 rename services/markup/{processorhelper_codepreview_test.go => renderhelper_codepreview_test.go} (95%)
 create mode 100644 services/markup/renderhelper_issueicontitle.go
 create mode 100644 services/markup/renderhelper_issueicontitle_test.go
 rename services/markup/{processorhelper_test.go => renderhelper_mention_test.go} (61%)

diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go
index 5794d5109e..12f3c25676 100644
--- a/models/unittest/testdb.go
+++ b/models/unittest/testdb.go
@@ -206,7 +206,7 @@ func CreateTestEngine(opts FixturesOptions) error {
 	x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate")
 	if err != nil {
 		if strings.Contains(err.Error(), "unknown driver") {
-			return fmt.Errorf(`sqlite3 requires: import _ "github.com/mattn/go-sqlite3" or -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err)
+			return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err)
 		}
 		return err
 	}
diff --git a/modules/markup/html_commit.go b/modules/markup/html_commit.go
index 358e7b06ba..aa1b7d034a 100644
--- a/modules/markup/html_commit.go
+++ b/modules/markup/html_commit.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/references"
 	"code.gitea.io/gitea/modules/util"
 
 	"golang.org/x/net/html"
@@ -194,3 +195,21 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
 		node = node.NextSibling.NextSibling
 	}
 }
+
+func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
+	next := node.NextSibling
+
+	for node != nil && node != next {
+		found, ref := references.FindRenderizableCommitCrossReference(node.Data)
+		if !found {
+			return
+		}
+
+		reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
+		linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, "commit", ref.CommitSha), LinkTypeApp)
+		link := createLink(ctx, linkHref, reftext, "commit")
+
+		replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
+		node = node.NextSibling.NextSibling
+	}
+}
diff --git a/modules/markup/html_issue.go b/modules/markup/html_issue.go
index e64ec76c3d..7a6f33011a 100644
--- a/modules/markup/html_issue.go
+++ b/modules/markup/html_issue.go
@@ -4,9 +4,9 @@
 package markup
 
 import (
+	"strconv"
 	"strings"
 
-	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/references"
@@ -16,8 +16,16 @@ import (
 	"code.gitea.io/gitea/modules/util"
 
 	"golang.org/x/net/html"
+	"golang.org/x/net/html/atom"
 )
 
+type RenderIssueIconTitleOptions struct {
+	OwnerName  string
+	RepoName   string
+	LinkHref   string
+	IssueIndex int64
+}
+
 func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
 	if ctx.RenderOptions.Metas == nil {
 		return
@@ -66,6 +74,27 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
 	}
 }
 
+func createIssueLinkContentWithSummary(ctx *RenderContext, linkHref string, ref *references.RenderizableReference) *html.Node {
+	if DefaultRenderHelperFuncs.RenderRepoIssueIconTitle == nil {
+		return nil
+	}
+	issueIndex, _ := strconv.ParseInt(ref.Issue, 10, 64)
+	h, err := DefaultRenderHelperFuncs.RenderRepoIssueIconTitle(ctx, RenderIssueIconTitleOptions{
+		OwnerName:  ref.Owner,
+		RepoName:   ref.Name,
+		LinkHref:   linkHref,
+		IssueIndex: issueIndex,
+	})
+	if err != nil {
+		log.Error("RenderRepoIssueIconTitle failed: %v", err)
+		return nil
+	}
+	if h == "" {
+		return nil
+	}
+	return &html.Node{Type: html.RawNode, Data: string(ctx.RenderInternal.ProtectSafeAttrs(h))}
+}
+
 func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
 	if ctx.RenderOptions.Metas == nil {
 		return
@@ -76,32 +105,28 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
 	// old logic: crossLinkOnly := ctx.RenderOptions.Metas["mode"] == "document" && !ctx.IsWiki
 	crossLinkOnly := ctx.RenderOptions.Metas["markupAllowShortIssuePattern"] != "true"
 
-	var (
-		found bool
-		ref   *references.RenderizableReference
-	)
+	var ref *references.RenderizableReference
 
 	next := node.NextSibling
-
 	for node != nil && node != next {
 		_, hasExtTrackFormat := ctx.RenderOptions.Metas["format"]
 
 		// Repos with external issue trackers might still need to reference local PRs
 		// We need to concern with the first one that shows up in the text, whichever it is
 		isNumericStyle := ctx.RenderOptions.Metas["style"] == "" || ctx.RenderOptions.Metas["style"] == IssueNameStyleNumeric
-		foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
+		refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
 
 		switch ctx.RenderOptions.Metas["style"] {
 		case "", IssueNameStyleNumeric:
-			found, ref = foundNumeric, refNumeric
+			ref = refNumeric
 		case IssueNameStyleAlphanumeric:
-			found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
+			ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
 		case IssueNameStyleRegexp:
 			pattern, err := regexplru.GetCompiled(ctx.RenderOptions.Metas["regexp"])
 			if err != nil {
 				return
 			}
-			found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
+			ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
 		}
 
 		// Repos with external issue trackers might still need to reference local PRs
@@ -109,17 +134,17 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
 		if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
 			// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
 			// Allow a free-pass when non-numeric pattern wasn't found.
-			if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) {
-				found = foundNumeric
+			if ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start {
 				ref = refNumeric
 			}
 		}
-		if !found {
+
+		if ref == nil {
 			return
 		}
 
 		var link *html.Node
-		reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
+		refText := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
 		if hasExtTrackFormat && !ref.IsPull {
 			ctx.RenderOptions.Metas["index"] = ref.Issue
 
@@ -129,18 +154,23 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
 				log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
 			}
 
-			link = createLink(ctx, res, reftext, "ref-issue ref-external-issue")
+			link = createLink(ctx, res, refText, "ref-issue ref-external-issue")
 		} else {
 			// Path determines the type of link that will be rendered. It's unknown at this point whether
 			// the linked item is actually a PR or an issue. Luckily it's of no real consequence because
 			// Gitea will redirect on click as appropriate.
+			issueOwner := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["user"], ref.Owner)
+			issueRepo := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["repo"], ref.Name)
 			issuePath := util.Iif(ref.IsPull, "pulls", "issues")
-			if ref.Owner == "" {
-				linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"], issuePath, ref.Issue), LinkTypeApp)
-				link = createLink(ctx, linkHref, reftext, "ref-issue")
-			} else {
-				linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, issuePath, ref.Issue), LinkTypeApp)
-				link = createLink(ctx, linkHref, reftext, "ref-issue")
+			linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(issueOwner, issueRepo, issuePath, ref.Issue), LinkTypeApp)
+
+			// at the moment, only render the issue index in a full line (or simple line) as icon+title
+			// otherwise it would be too noisy for "take #1 as an example" in a sentence
+			if node.Parent.DataAtom == atom.Li && ref.RefLocation.Start < 20 && ref.RefLocation.End == len(node.Data) {
+				link = createIssueLinkContentWithSummary(ctx, linkHref, ref)
+			}
+			if link == nil {
+				link = createLink(ctx, linkHref, refText, "ref-issue")
 			}
 		}
 
@@ -168,21 +198,3 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
 		node = node.NextSibling.NextSibling.NextSibling.NextSibling
 	}
 }
-
-func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
-	next := node.NextSibling
-
-	for node != nil && node != next {
-		found, ref := references.FindRenderizableCommitCrossReference(node.Data)
-		if !found {
-			return
-		}
-
-		reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
-		linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, "commit", ref.CommitSha), LinkTypeApp)
-		link := createLink(ctx, linkHref, reftext, "commit")
-
-		replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
-		node = node.NextSibling.NextSibling
-	}
-}
diff --git a/modules/markup/html_issue_test.go b/modules/markup/html_issue_test.go
new file mode 100644
index 0000000000..8d189fbdf6
--- /dev/null
+++ b/modules/markup/html_issue_test.go
@@ -0,0 +1,72 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup_test
+
+import (
+	"context"
+	"html/template"
+	"strings"
+	"testing"
+
+	"code.gitea.io/gitea/modules/htmlutil"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/markup/markdown"
+	testModule "code.gitea.io/gitea/modules/test"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRender_IssueList(t *testing.T) {
+	defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
+	markup.Init(&markup.RenderHelperFuncs{
+		RenderRepoIssueIconTitle: func(ctx context.Context, opts markup.RenderIssueIconTitleOptions) (template.HTML, error) {
+			return htmlutil.HTMLFormat("<div>issue #%d</div>", opts.IssueIndex), nil
+		},
+	})
+
+	test := func(input, expected string) {
+		rctx := markup.NewTestRenderContext(markup.TestAppURL, map[string]string{
+			"user": "test-user", "repo": "test-repo",
+			"markupAllowShortIssuePattern": "true",
+		})
+		out, err := markdown.RenderString(rctx, input)
+		require.NoError(t, err)
+		assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out)))
+	}
+
+	t.Run("NormalIssueRef", func(t *testing.T) {
+		test(
+			"#12345",
+			`<p><a href="http://localhost:3000/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a></p>`,
+		)
+	})
+
+	t.Run("ListIssueRef", func(t *testing.T) {
+		test(
+			"* #12345",
+			`<ul>
+<li><div>issue #12345</div></li>
+</ul>`,
+		)
+	})
+
+	t.Run("ListIssueRefNormal", func(t *testing.T) {
+		test(
+			"* foo #12345 bar",
+			`<ul>
+<li>foo <a href="http://localhost:3000/test-user/test-repo/issues/12345" class="ref-issue" rel="nofollow">#12345</a> bar</li>
+</ul>`,
+		)
+	})
+
+	t.Run("ListTodoIssueRef", func(t *testing.T) {
+		test(
+			"* [ ] #12345",
+			`<ul>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="2"/><div>issue #12345</div></li>
+</ul>`,
+		)
+	})
+}
diff --git a/modules/markup/render_helper.go b/modules/markup/render_helper.go
index 82796ef274..8ff0e7d6fb 100644
--- a/modules/markup/render_helper.go
+++ b/modules/markup/render_helper.go
@@ -38,6 +38,7 @@ type RenderHelper interface {
 type RenderHelperFuncs struct {
 	IsUsernameMentionable     func(ctx context.Context, username string) bool
 	RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
+	RenderRepoIssueIconTitle  func(ctx context.Context, options RenderIssueIconTitleOptions) (template.HTML, error)
 }
 
 var DefaultRenderHelperFuncs *RenderHelperFuncs
diff --git a/modules/references/references.go b/modules/references/references.go
index dcb70a33d0..a5b102b7f2 100644
--- a/modules/references/references.go
+++ b/modules/references/references.go
@@ -330,22 +330,22 @@ func FindAllIssueReferences(content string) []IssueReference {
 }
 
 // FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string.
-func FindRenderizableReferenceNumeric(content string, prOnly, crossLinkOnly bool) (bool, *RenderizableReference) {
+func FindRenderizableReferenceNumeric(content string, prOnly, crossLinkOnly bool) *RenderizableReference {
 	var match []int
 	if !crossLinkOnly {
 		match = issueNumericPattern.FindStringSubmatchIndex(content)
 	}
 	if match == nil {
 		if match = crossReferenceIssueNumericPattern.FindStringSubmatchIndex(content); match == nil {
-			return false, nil
+			return nil
 		}
 	}
 	r := getCrossReference(util.UnsafeStringToBytes(content), match[2], match[3], false, prOnly)
 	if r == nil {
-		return false, nil
+		return nil
 	}
 
-	return true, &RenderizableReference{
+	return &RenderizableReference{
 		Issue:          r.issue,
 		Owner:          r.owner,
 		Name:           r.name,
@@ -372,15 +372,14 @@ func FindRenderizableCommitCrossReference(content string) (bool, *RenderizableRe
 }
 
 // FindRenderizableReferenceRegexp returns the first regexp unvalidated references found in a string.
-func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bool, *RenderizableReference) {
+func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) *RenderizableReference {
 	match := pattern.FindStringSubmatchIndex(content)
 	if len(match) < 4 {
-		return false, nil
+		return nil
 	}
 
 	action, location := findActionKeywords([]byte(content), match[2])
-
-	return true, &RenderizableReference{
+	return &RenderizableReference{
 		Issue:          content[match[2]:match[3]],
 		RefLocation:    &RefSpan{Start: match[0], End: match[1]},
 		Action:         action,
@@ -390,15 +389,14 @@ func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bo
 }
 
 // FindRenderizableReferenceAlphanumeric returns the first alphanumeric unvalidated references found in a string.
-func FindRenderizableReferenceAlphanumeric(content string) (bool, *RenderizableReference) {
+func FindRenderizableReferenceAlphanumeric(content string) *RenderizableReference {
 	match := issueAlphanumericPattern.FindStringSubmatchIndex(content)
 	if match == nil {
-		return false, nil
+		return nil
 	}
 
 	action, location := findActionKeywords([]byte(content), match[2])
-
-	return true, &RenderizableReference{
+	return &RenderizableReference{
 		Issue:          content[match[2]:match[3]],
 		RefLocation:    &RefSpan{Start: match[2], End: match[3]},
 		Action:         action,
diff --git a/modules/references/references_test.go b/modules/references/references_test.go
index 27803083c0..1b6a968d6a 100644
--- a/modules/references/references_test.go
+++ b/modules/references/references_test.go
@@ -249,11 +249,10 @@ func TestFindAllIssueReferences(t *testing.T) {
 	}
 
 	for _, fixture := range alnumFixtures {
-		found, ref := FindRenderizableReferenceAlphanumeric(fixture.input)
+		ref := FindRenderizableReferenceAlphanumeric(fixture.input)
 		if fixture.issue == "" {
-			assert.False(t, found, "Failed to parse: {%s}", fixture.input)
+			assert.Nil(t, ref, "Failed to parse: {%s}", fixture.input)
 		} else {
-			assert.True(t, found, "Failed to parse: {%s}", fixture.input)
 			assert.Equal(t, fixture.issue, ref.Issue, "Failed to parse: {%s}", fixture.input)
 			assert.Equal(t, fixture.refLocation, ref.RefLocation, "Failed to parse: {%s}", fixture.input)
 			assert.Equal(t, fixture.action, ref.Action, "Failed to parse: {%s}", fixture.input)
diff --git a/modules/svg/processor.go b/modules/svg/processor.go
index 82248fb0c1..4fcb11a57d 100644
--- a/modules/svg/processor.go
+++ b/modules/svg/processor.go
@@ -10,7 +10,7 @@ import (
 	"sync"
 )
 
-type normalizeVarsStruct struct {
+type globalVarsStruct struct {
 	reXMLDoc,
 	reComment,
 	reAttrXMLNs,
@@ -18,26 +18,23 @@ type normalizeVarsStruct struct {
 	reAttrClassPrefix *regexp.Regexp
 }
 
-var (
-	normalizeVars     *normalizeVarsStruct
-	normalizeVarsOnce sync.Once
-)
+var globalVars = sync.OnceValue(func() *globalVarsStruct {
+	return &globalVarsStruct{
+		reXMLDoc:  regexp.MustCompile(`(?s)<\?xml.*?>`),
+		reComment: regexp.MustCompile(`(?s)<!--.*?-->`),
+
+		reAttrXMLNs:       regexp.MustCompile(`(?s)\s+xmlns\s*=\s*"[^"]*"`),
+		reAttrSize:        regexp.MustCompile(`(?s)\s+(width|height)\s*=\s*"[^"]+"`),
+		reAttrClassPrefix: regexp.MustCompile(`(?s)\s+class\s*=\s*"`),
+	}
+})
 
 // Normalize normalizes the SVG content: set default width/height, remove unnecessary tags/attributes
 // It's designed to work with valid SVG content. For invalid SVG content, the returned content is not guaranteed.
 func Normalize(data []byte, size int) []byte {
-	normalizeVarsOnce.Do(func() {
-		normalizeVars = &normalizeVarsStruct{
-			reXMLDoc:  regexp.MustCompile(`(?s)<\?xml.*?>`),
-			reComment: regexp.MustCompile(`(?s)<!--.*?-->`),
-
-			reAttrXMLNs:       regexp.MustCompile(`(?s)\s+xmlns\s*=\s*"[^"]*"`),
-			reAttrSize:        regexp.MustCompile(`(?s)\s+(width|height)\s*=\s*"[^"]+"`),
-			reAttrClassPrefix: regexp.MustCompile(`(?s)\s+class\s*=\s*"`),
-		}
-	})
-	data = normalizeVars.reXMLDoc.ReplaceAll(data, nil)
-	data = normalizeVars.reComment.ReplaceAll(data, nil)
+	vars := globalVars()
+	data = vars.reXMLDoc.ReplaceAll(data, nil)
+	data = vars.reComment.ReplaceAll(data, nil)
 
 	data = bytes.TrimSpace(data)
 	svgTag, svgRemaining, ok := bytes.Cut(data, []byte(">"))
@@ -45,9 +42,9 @@ func Normalize(data []byte, size int) []byte {
 		return data
 	}
 	normalized := bytes.Clone(svgTag)
-	normalized = normalizeVars.reAttrXMLNs.ReplaceAll(normalized, nil)
-	normalized = normalizeVars.reAttrSize.ReplaceAll(normalized, nil)
-	normalized = normalizeVars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
+	normalized = vars.reAttrXMLNs.ReplaceAll(normalized, nil)
+	normalized = vars.reAttrSize.ReplaceAll(normalized, nil)
+	normalized = vars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
 	normalized = bytes.TrimSpace(normalized)
 	normalized = fmt.Appendf(normalized, ` width="%d" height="%d"`, size, size)
 	if !bytes.Contains(normalized, []byte(` class="`)) {
diff --git a/routers/init.go b/routers/init.go
index 98ce1bc4c9..e7aa765bf0 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -133,7 +133,7 @@ func InitWebInstalled(ctx context.Context) {
 
 	highlight.NewContext()
 	external.RegisterRenderers()
-	markup.Init(markup_service.ProcessorHelper())
+	markup.Init(markup_service.FormalRenderHelperFuncs())
 
 	if setting.EnableSQLite3 {
 		log.Info("SQLite3 support is enabled")
diff --git a/services/context/context_response.go b/services/context/context_response.go
index 4c086ea9f5..c7044791eb 100644
--- a/services/context/context_response.go
+++ b/services/context/context_response.go
@@ -106,7 +106,7 @@ func (ctx *Context) JSONTemplate(tmpl templates.TplName) {
 }
 
 // RenderToHTML renders the template content to a HTML string
-func (ctx *Context) RenderToHTML(name templates.TplName, data map[string]any) (template.HTML, error) {
+func (ctx *Context) RenderToHTML(name templates.TplName, data any) (template.HTML, error) {
 	var buf strings.Builder
 	err := ctx.Render.HTML(&buf, 0, name, data, ctx.TemplateContext)
 	return template.HTML(buf.String()), err
diff --git a/services/markup/main_test.go b/services/markup/main_test.go
index 5553ebc058..d04a18bfa1 100644
--- a/services/markup/main_test.go
+++ b/services/markup/main_test.go
@@ -11,6 +11,6 @@ import (
 
 func TestMain(m *testing.M) {
 	unittest.MainTest(m, &unittest.TestOptions{
-		FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"},
+		FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml", "issue.yml"},
 	})
 }
diff --git a/services/markup/processorhelper.go b/services/markup/renderhelper.go
similarity index 88%
rename from services/markup/processorhelper.go
rename to services/markup/renderhelper.go
index 1f1abf496a..4b9852b48b 100644
--- a/services/markup/processorhelper.go
+++ b/services/markup/renderhelper.go
@@ -11,9 +11,10 @@ import (
 	gitea_context "code.gitea.io/gitea/services/context"
 )
 
-func ProcessorHelper() *markup.RenderHelperFuncs {
+func FormalRenderHelperFuncs() *markup.RenderHelperFuncs {
 	return &markup.RenderHelperFuncs{
 		RenderRepoFileCodePreview: renderRepoFileCodePreview,
+		RenderRepoIssueIconTitle:  renderRepoIssueIconTitle,
 		IsUsernameMentionable: func(ctx context.Context, username string) bool {
 			mentionedUser, err := user.GetUserByName(ctx, username)
 			if err != nil {
diff --git a/services/markup/processorhelper_codepreview.go b/services/markup/renderhelper_codepreview.go
similarity index 97%
rename from services/markup/processorhelper_codepreview.go
rename to services/markup/renderhelper_codepreview.go
index 0500e57e46..170c70c409 100644
--- a/services/markup/processorhelper_codepreview.go
+++ b/services/markup/renderhelper_codepreview.go
@@ -18,6 +18,7 @@ import (
 	"code.gitea.io/gitea/modules/indexer/code"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
 	gitea_context "code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/repository/files"
 )
@@ -46,7 +47,7 @@ func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePrevie
 		return "", err
 	}
 	if !perms.CanRead(unit.TypeCode) {
-		return "", fmt.Errorf("no permission")
+		return "", util.ErrPermissionDenied
 	}
 
 	gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo)
diff --git a/services/markup/processorhelper_codepreview_test.go b/services/markup/renderhelper_codepreview_test.go
similarity index 95%
rename from services/markup/processorhelper_codepreview_test.go
rename to services/markup/renderhelper_codepreview_test.go
index 154e4e8e44..ea945584b4 100644
--- a/services/markup/processorhelper_codepreview_test.go
+++ b/services/markup/renderhelper_codepreview_test.go
@@ -9,12 +9,13 @@ import (
 	"code.gitea.io/gitea/models/unittest"
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/contexttest"
 
 	"github.com/stretchr/testify/assert"
 )
 
-func TestProcessorHelperCodePreview(t *testing.T) {
+func TestRenderHelperCodePreview(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
@@ -79,5 +80,5 @@ func TestProcessorHelperCodePreview(t *testing.T) {
 		LineStart: 1,
 		LineStop:  10,
 	})
-	assert.ErrorContains(t, err, "no permission")
+	assert.ErrorIs(t, err, util.ErrPermissionDenied)
 }
diff --git a/services/markup/renderhelper_issueicontitle.go b/services/markup/renderhelper_issueicontitle.go
new file mode 100644
index 0000000000..53a508e908
--- /dev/null
+++ b/services/markup/renderhelper_issueicontitle.go
@@ -0,0 +1,66 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+	"context"
+	"fmt"
+	"html/template"
+
+	"code.gitea.io/gitea/models/issues"
+	"code.gitea.io/gitea/models/perm/access"
+	"code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/modules/htmlutil"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/util"
+	gitea_context "code.gitea.io/gitea/services/context"
+)
+
+func renderRepoIssueIconTitle(ctx context.Context, opts markup.RenderIssueIconTitleOptions) (_ template.HTML, err error) {
+	webCtx, ok := ctx.Value(gitea_context.WebContextKey).(*gitea_context.Context)
+	if !ok {
+		return "", fmt.Errorf("context is not a web context")
+	}
+
+	textIssueIndex := fmt.Sprintf("(#%d)", opts.IssueIndex)
+	dbRepo := webCtx.Repo.Repository
+	if opts.OwnerName != "" {
+		dbRepo, err = repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName)
+		if err != nil {
+			return "", err
+		}
+		textIssueIndex = fmt.Sprintf("(%s/%s#%d)", dbRepo.OwnerName, dbRepo.Name, opts.IssueIndex)
+	}
+	if dbRepo == nil {
+		return "", nil
+	}
+
+	issue, err := issues.GetIssueByIndex(ctx, dbRepo.ID, opts.IssueIndex)
+	if err != nil {
+		return "", err
+	}
+
+	if webCtx.Repo.Repository == nil || dbRepo.ID != webCtx.Repo.Repository.ID {
+		perms, err := access.GetUserRepoPermission(ctx, dbRepo, webCtx.Doer)
+		if err != nil {
+			return "", err
+		}
+		if !perms.CanReadIssuesOrPulls(issue.IsPull) {
+			return "", util.ErrPermissionDenied
+		}
+	}
+
+	if issue.IsPull {
+		if err = issue.LoadPullRequest(ctx); err != nil {
+			return "", err
+		}
+	}
+
+	htmlIcon, err := webCtx.RenderToHTML("shared/issueicon", issue)
+	if err != nil {
+		return "", err
+	}
+
+	return htmlutil.HTMLFormat(`<a href="%s">%s %s %s</a>`, opts.LinkHref, htmlIcon, issue.Title, textIssueIndex), nil
+}
diff --git a/services/markup/renderhelper_issueicontitle_test.go b/services/markup/renderhelper_issueicontitle_test.go
new file mode 100644
index 0000000000..adce8401e0
--- /dev/null
+++ b/services/markup/renderhelper_issueicontitle_test.go
@@ -0,0 +1,49 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markup
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/markup"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/services/contexttest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRenderHelperIssueIconTitle(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
+	htm, err := renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
+		LinkHref:   "/link",
+		IssueIndex: 1,
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (#1)</a>`, string(htm))
+
+	ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	htm, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
+		OwnerName:  "user2",
+		RepoName:   "repo1",
+		LinkHref:   "/link",
+		IssueIndex: 1,
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, `<a href="/link"><span>octicon-issue-opened(16/text green)</span> issue1 (user2/repo1#1)</a>`, string(htm))
+
+	ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
+	_, err = renderRepoIssueIconTitle(ctx, markup.RenderIssueIconTitleOptions{
+		OwnerName:  "user2",
+		RepoName:   "repo2",
+		LinkHref:   "/link",
+		IssueIndex: 2,
+	})
+	assert.ErrorIs(t, err, util.ErrPermissionDenied)
+}
diff --git a/services/markup/processorhelper_test.go b/services/markup/renderhelper_mention_test.go
similarity index 61%
rename from services/markup/processorhelper_test.go
rename to services/markup/renderhelper_mention_test.go
index 170edae0e0..f0c0eb9926 100644
--- a/services/markup/processorhelper_test.go
+++ b/services/markup/renderhelper_mention_test.go
@@ -18,7 +18,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestProcessorHelper(t *testing.T) {
+func TestRenderHelperMention(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	userPublic := "user1"
@@ -32,10 +32,10 @@ func TestProcessorHelper(t *testing.T) {
 	unittest.AssertCount(t, &user.User{Name: userNoSuch}, 0)
 
 	// when using general context, use user's visibility to check
-	assert.True(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userPublic))
-	assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userLimited))
-	assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userPrivate))
-	assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userNoSuch))
+	assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userPublic))
+	assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userLimited))
+	assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userPrivate))
+	assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userNoSuch))
 
 	// when using web context, use user.IsUserVisibleToViewer to check
 	req, err := http.NewRequest("GET", "/", nil)
@@ -44,11 +44,11 @@ func TestProcessorHelper(t *testing.T) {
 	defer baseCleanUp()
 	giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
 
-	assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic))
-	assert.False(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate))
+	assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
+	assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPrivate))
 
 	giteaCtx.Doer, err = user.GetUserByName(db.DefaultContext, userPrivate)
 	assert.NoError(t, err)
-	assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic))
-	assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate))
+	assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
+	assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPrivate))
 }
diff --git a/templates/shared/issueicon.tmpl b/templates/shared/issueicon.tmpl
index a62714e988..f828de5c66 100644
--- a/templates/shared/issueicon.tmpl
+++ b/templates/shared/issueicon.tmpl
@@ -1,25 +1,25 @@
-{{if .IsPull}}
-	{{if not .PullRequest}}
+{{- if .IsPull -}}
+	{{- if not .PullRequest -}}
 		No PullRequest
-	{{else}}
-		{{if .IsClosed}}
-			{{if .PullRequest.HasMerged}}
-				{{svg "octicon-git-merge" 16 "text purple"}}
-			{{else}}
-				{{svg "octicon-git-pull-request" 16 "text red"}}
-			{{end}}
-		{{else}}
-			{{if .PullRequest.IsWorkInProgress ctx}}
-				{{svg "octicon-git-pull-request-draft" 16 "text grey"}}
-			{{else}}
-				{{svg "octicon-git-pull-request" 16 "text green"}}
-			{{end}}
-		{{end}}
-	{{end}}
-{{else}}
-	{{if .IsClosed}}
-		{{svg "octicon-issue-closed" 16 "text red"}}
-	{{else}}
-		{{svg "octicon-issue-opened" 16 "text green"}}
-	{{end}}
-{{end}}
+	{{- else -}}
+		{{- if .IsClosed -}}
+			{{- if .PullRequest.HasMerged -}}
+				{{- svg "octicon-git-merge" 16 "text purple" -}}
+			{{- else -}}
+				{{- svg "octicon-git-pull-request" 16 "text red" -}}
+			{{- end -}}
+		{{- else -}}
+			{{- if .PullRequest.IsWorkInProgress ctx -}}
+				{{- svg "octicon-git-pull-request-draft" 16 "text grey" -}}
+			{{- else -}}
+				{{- svg "octicon-git-pull-request" 16 "text green" -}}
+			{{- end -}}
+		{{- end -}}
+	{{- end -}}
+{{- else -}}
+	{{- if .IsClosed -}}
+		{{- svg "octicon-issue-closed" 16 "text red" -}}
+	{{- else -}}
+		{{- svg "octicon-issue-opened" 16 "text green" -}}
+	{{- end -}}
+{{- end -}}
diff --git a/tests/test_utils.go b/tests/test_utils.go
index 0fe0200ea7..96eb5731b4 100644
--- a/tests/test_utils.go
+++ b/tests/test_utils.go
@@ -58,7 +58,7 @@ func InitTest(requireGitea bool) {
 		_ = os.Setenv("GITEA_CONF", giteaConf)
 		fmt.Printf("Environment variable $GITEA_CONF not set, use default: %s\n", giteaConf)
 		if !setting.EnableSQLite3 {
-			testlogger.Fatalf(`sqlite3 requires: import _ "github.com/mattn/go-sqlite3" or -tags sqlite,sqlite_unlock_notify` + "\n")
+			testlogger.Fatalf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify` + "\n")
 		}
 	}
 	if !filepath.IsAbs(giteaConf) {