1
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-01-04 09:17:43 +03:00

Add sub issue list support (#32940)

Just like GitHub, show issue icon/title when the issue number is in a list
This commit is contained in:
wxiaoguang 2024-12-24 09:54:19 +08:00 committed by GitHub
parent 02c64e48b7
commit 781c6df40f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 332 additions and 116 deletions

View File

@ -206,7 +206,7 @@ func CreateTestEngine(opts FixturesOptions) error {
x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate") x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate")
if err != nil { if err != nil {
if strings.Contains(err.Error(), "unknown driver") { 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 return err
} }

View File

@ -8,6 +8,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"golang.org/x/net/html" "golang.org/x/net/html"
@ -194,3 +195,21 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
node = node.NextSibling.NextSibling 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
}
}

View File

@ -4,9 +4,9 @@
package markup package markup
import ( import (
"strconv"
"strings" "strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/references"
@ -16,8 +16,16 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"golang.org/x/net/html" "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) { func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.RenderOptions.Metas == nil { if ctx.RenderOptions.Metas == nil {
return 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) { func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.RenderOptions.Metas == nil { if ctx.RenderOptions.Metas == nil {
return return
@ -76,32 +105,28 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
// old logic: crossLinkOnly := ctx.RenderOptions.Metas["mode"] == "document" && !ctx.IsWiki // old logic: crossLinkOnly := ctx.RenderOptions.Metas["mode"] == "document" && !ctx.IsWiki
crossLinkOnly := ctx.RenderOptions.Metas["markupAllowShortIssuePattern"] != "true" crossLinkOnly := ctx.RenderOptions.Metas["markupAllowShortIssuePattern"] != "true"
var ( var ref *references.RenderizableReference
found bool
ref *references.RenderizableReference
)
next := node.NextSibling next := node.NextSibling
for node != nil && node != next { for node != nil && node != next {
_, hasExtTrackFormat := ctx.RenderOptions.Metas["format"] _, hasExtTrackFormat := ctx.RenderOptions.Metas["format"]
// Repos with external issue trackers might still need to reference local PRs // 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 // 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 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"] { switch ctx.RenderOptions.Metas["style"] {
case "", IssueNameStyleNumeric: case "", IssueNameStyleNumeric:
found, ref = foundNumeric, refNumeric ref = refNumeric
case IssueNameStyleAlphanumeric: case IssueNameStyleAlphanumeric:
found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data) ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
case IssueNameStyleRegexp: case IssueNameStyleRegexp:
pattern, err := regexplru.GetCompiled(ctx.RenderOptions.Metas["regexp"]) pattern, err := regexplru.GetCompiled(ctx.RenderOptions.Metas["regexp"])
if err != nil { if err != nil {
return 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 // 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 hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that // 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. // Allow a free-pass when non-numeric pattern wasn't found.
if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) { if ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start {
found = foundNumeric
ref = refNumeric ref = refNumeric
} }
} }
if !found {
if ref == nil {
return return
} }
var link *html.Node 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 { if hasExtTrackFormat && !ref.IsPull {
ctx.RenderOptions.Metas["index"] = ref.Issue 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) 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 { } else {
// Path determines the type of link that will be rendered. It's unknown at this point whether // 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 // 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. // 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") issuePath := util.Iif(ref.IsPull, "pulls", "issues")
if ref.Owner == "" { linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(issueOwner, issueRepo, issuePath, ref.Issue), LinkTypeApp)
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") // at the moment, only render the issue index in a full line (or simple line) as icon+title
} else { // otherwise it would be too noisy for "take #1 as an example" in a sentence
linkHref := ctx.RenderHelper.ResolveLink(util.URLJoin(ref.Owner, ref.Name, issuePath, ref.Issue), LinkTypeApp) if node.Parent.DataAtom == atom.Li && ref.RefLocation.Start < 20 && ref.RefLocation.End == len(node.Data) {
link = createLink(ctx, linkHref, reftext, "ref-issue") 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 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
}
}

View File

@ -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>`,
)
})
}

View File

@ -38,6 +38,7 @@ type RenderHelper interface {
type RenderHelperFuncs struct { type RenderHelperFuncs struct {
IsUsernameMentionable func(ctx context.Context, username string) bool IsUsernameMentionable func(ctx context.Context, username string) bool
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error) RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
RenderRepoIssueIconTitle func(ctx context.Context, options RenderIssueIconTitleOptions) (template.HTML, error)
} }
var DefaultRenderHelperFuncs *RenderHelperFuncs var DefaultRenderHelperFuncs *RenderHelperFuncs

View File

@ -330,22 +330,22 @@ func FindAllIssueReferences(content string) []IssueReference {
} }
// FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string. // 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 var match []int
if !crossLinkOnly { if !crossLinkOnly {
match = issueNumericPattern.FindStringSubmatchIndex(content) match = issueNumericPattern.FindStringSubmatchIndex(content)
} }
if match == nil { if match == nil {
if match = crossReferenceIssueNumericPattern.FindStringSubmatchIndex(content); 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) r := getCrossReference(util.UnsafeStringToBytes(content), match[2], match[3], false, prOnly)
if r == nil { if r == nil {
return false, nil return nil
} }
return true, &RenderizableReference{ return &RenderizableReference{
Issue: r.issue, Issue: r.issue,
Owner: r.owner, Owner: r.owner,
Name: r.name, Name: r.name,
@ -372,15 +372,14 @@ func FindRenderizableCommitCrossReference(content string) (bool, *RenderizableRe
} }
// FindRenderizableReferenceRegexp returns the first regexp unvalidated references found in a string. // 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) match := pattern.FindStringSubmatchIndex(content)
if len(match) < 4 { if len(match) < 4 {
return false, nil return nil
} }
action, location := findActionKeywords([]byte(content), match[2]) action, location := findActionKeywords([]byte(content), match[2])
return &RenderizableReference{
return true, &RenderizableReference{
Issue: content[match[2]:match[3]], Issue: content[match[2]:match[3]],
RefLocation: &RefSpan{Start: match[0], End: match[1]}, RefLocation: &RefSpan{Start: match[0], End: match[1]},
Action: action, 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. // 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) match := issueAlphanumericPattern.FindStringSubmatchIndex(content)
if match == nil { if match == nil {
return false, nil return nil
} }
action, location := findActionKeywords([]byte(content), match[2]) action, location := findActionKeywords([]byte(content), match[2])
return &RenderizableReference{
return true, &RenderizableReference{
Issue: content[match[2]:match[3]], Issue: content[match[2]:match[3]],
RefLocation: &RefSpan{Start: match[2], End: match[3]}, RefLocation: &RefSpan{Start: match[2], End: match[3]},
Action: action, Action: action,

View File

@ -249,11 +249,10 @@ func TestFindAllIssueReferences(t *testing.T) {
} }
for _, fixture := range alnumFixtures { for _, fixture := range alnumFixtures {
found, ref := FindRenderizableReferenceAlphanumeric(fixture.input) ref := FindRenderizableReferenceAlphanumeric(fixture.input)
if fixture.issue == "" { if fixture.issue == "" {
assert.False(t, found, "Failed to parse: {%s}", fixture.input) assert.Nil(t, ref, "Failed to parse: {%s}", fixture.input)
} else { } 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.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.refLocation, ref.RefLocation, "Failed to parse: {%s}", fixture.input)
assert.Equal(t, fixture.action, ref.Action, "Failed to parse: {%s}", fixture.input) assert.Equal(t, fixture.action, ref.Action, "Failed to parse: {%s}", fixture.input)

View File

@ -10,7 +10,7 @@ import (
"sync" "sync"
) )
type normalizeVarsStruct struct { type globalVarsStruct struct {
reXMLDoc, reXMLDoc,
reComment, reComment,
reAttrXMLNs, reAttrXMLNs,
@ -18,26 +18,23 @@ type normalizeVarsStruct struct {
reAttrClassPrefix *regexp.Regexp reAttrClassPrefix *regexp.Regexp
} }
var ( var globalVars = sync.OnceValue(func() *globalVarsStruct {
normalizeVars *normalizeVarsStruct return &globalVarsStruct{
normalizeVarsOnce sync.Once 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 // 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. // 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 { func Normalize(data []byte, size int) []byte {
normalizeVarsOnce.Do(func() { vars := globalVars()
normalizeVars = &normalizeVarsStruct{ data = vars.reXMLDoc.ReplaceAll(data, nil)
reXMLDoc: regexp.MustCompile(`(?s)<\?xml.*?>`), data = vars.reComment.ReplaceAll(data, nil)
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)
data = bytes.TrimSpace(data) data = bytes.TrimSpace(data)
svgTag, svgRemaining, ok := bytes.Cut(data, []byte(">")) svgTag, svgRemaining, ok := bytes.Cut(data, []byte(">"))
@ -45,9 +42,9 @@ func Normalize(data []byte, size int) []byte {
return data return data
} }
normalized := bytes.Clone(svgTag) normalized := bytes.Clone(svgTag)
normalized = normalizeVars.reAttrXMLNs.ReplaceAll(normalized, nil) normalized = vars.reAttrXMLNs.ReplaceAll(normalized, nil)
normalized = normalizeVars.reAttrSize.ReplaceAll(normalized, nil) normalized = vars.reAttrSize.ReplaceAll(normalized, nil)
normalized = normalizeVars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`)) normalized = vars.reAttrClassPrefix.ReplaceAll(normalized, []byte(` class="`))
normalized = bytes.TrimSpace(normalized) normalized = bytes.TrimSpace(normalized)
normalized = fmt.Appendf(normalized, ` width="%d" height="%d"`, size, size) normalized = fmt.Appendf(normalized, ` width="%d" height="%d"`, size, size)
if !bytes.Contains(normalized, []byte(` class="`)) { if !bytes.Contains(normalized, []byte(` class="`)) {

View File

@ -133,7 +133,7 @@ func InitWebInstalled(ctx context.Context) {
highlight.NewContext() highlight.NewContext()
external.RegisterRenderers() external.RegisterRenderers()
markup.Init(markup_service.ProcessorHelper()) markup.Init(markup_service.FormalRenderHelperFuncs())
if setting.EnableSQLite3 { if setting.EnableSQLite3 {
log.Info("SQLite3 support is enabled") log.Info("SQLite3 support is enabled")

View File

@ -106,7 +106,7 @@ func (ctx *Context) JSONTemplate(tmpl templates.TplName) {
} }
// RenderToHTML renders the template content to a HTML string // 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 var buf strings.Builder
err := ctx.Render.HTML(&buf, 0, name, data, ctx.TemplateContext) err := ctx.Render.HTML(&buf, 0, name, data, ctx.TemplateContext)
return template.HTML(buf.String()), err return template.HTML(buf.String()), err

View File

@ -11,6 +11,6 @@ import (
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{ 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"},
}) })
} }

View File

@ -11,9 +11,10 @@ import (
gitea_context "code.gitea.io/gitea/services/context" gitea_context "code.gitea.io/gitea/services/context"
) )
func ProcessorHelper() *markup.RenderHelperFuncs { func FormalRenderHelperFuncs() *markup.RenderHelperFuncs {
return &markup.RenderHelperFuncs{ return &markup.RenderHelperFuncs{
RenderRepoFileCodePreview: renderRepoFileCodePreview, RenderRepoFileCodePreview: renderRepoFileCodePreview,
RenderRepoIssueIconTitle: renderRepoIssueIconTitle,
IsUsernameMentionable: func(ctx context.Context, username string) bool { IsUsernameMentionable: func(ctx context.Context, username string) bool {
mentionedUser, err := user.GetUserByName(ctx, username) mentionedUser, err := user.GetUserByName(ctx, username)
if err != nil { if err != nil {

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
gitea_context "code.gitea.io/gitea/services/context" gitea_context "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/repository/files" "code.gitea.io/gitea/services/repository/files"
) )
@ -46,7 +47,7 @@ func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePrevie
return "", err return "", err
} }
if !perms.CanRead(unit.TypeCode) { if !perms.CanRead(unit.TypeCode) {
return "", fmt.Errorf("no permission") return "", util.ErrPermissionDenied
} }
gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo) gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo)

View File

@ -9,12 +9,13 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestProcessorHelperCodePreview(t *testing.T) { func TestRenderHelperCodePreview(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()})
@ -79,5 +80,5 @@ func TestProcessorHelperCodePreview(t *testing.T) {
LineStart: 1, LineStart: 1,
LineStop: 10, LineStop: 10,
}) })
assert.ErrorContains(t, err, "no permission") assert.ErrorIs(t, err, util.ErrPermissionDenied)
} }

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestProcessorHelper(t *testing.T) { func TestRenderHelperMention(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
userPublic := "user1" userPublic := "user1"
@ -32,10 +32,10 @@ func TestProcessorHelper(t *testing.T) {
unittest.AssertCount(t, &user.User{Name: userNoSuch}, 0) unittest.AssertCount(t, &user.User{Name: userNoSuch}, 0)
// when using general context, use user's visibility to check // when using general context, use user's visibility to check
assert.True(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userPublic)) assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userPublic))
assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userLimited)) assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userLimited))
assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userPrivate)) assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userPrivate))
assert.False(t, ProcessorHelper().IsUsernameMentionable(context.Background(), userNoSuch)) assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(context.Background(), userNoSuch))
// when using web context, use user.IsUserVisibleToViewer to check // when using web context, use user.IsUserVisibleToViewer to check
req, err := http.NewRequest("GET", "/", nil) req, err := http.NewRequest("GET", "/", nil)
@ -44,11 +44,11 @@ func TestProcessorHelper(t *testing.T) {
defer baseCleanUp() defer baseCleanUp()
giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil) giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic)) assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
assert.False(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate)) assert.False(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPrivate))
giteaCtx.Doer, err = user.GetUserByName(db.DefaultContext, userPrivate) giteaCtx.Doer, err = user.GetUserByName(db.DefaultContext, userPrivate)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic)) assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate)) assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPrivate))
} }

View File

@ -1,25 +1,25 @@
{{if .IsPull}} {{- if .IsPull -}}
{{if not .PullRequest}} {{- if not .PullRequest -}}
No PullRequest No PullRequest
{{else}} {{- else -}}
{{if .IsClosed}} {{- if .IsClosed -}}
{{if .PullRequest.HasMerged}} {{- if .PullRequest.HasMerged -}}
{{svg "octicon-git-merge" 16 "text purple"}} {{- svg "octicon-git-merge" 16 "text purple" -}}
{{else}} {{- else -}}
{{svg "octicon-git-pull-request" 16 "text red"}} {{- svg "octicon-git-pull-request" 16 "text red" -}}
{{end}} {{- end -}}
{{else}} {{- else -}}
{{if .PullRequest.IsWorkInProgress ctx}} {{- if .PullRequest.IsWorkInProgress ctx -}}
{{svg "octicon-git-pull-request-draft" 16 "text grey"}} {{- svg "octicon-git-pull-request-draft" 16 "text grey" -}}
{{else}} {{- else -}}
{{svg "octicon-git-pull-request" 16 "text green"}} {{- svg "octicon-git-pull-request" 16 "text green" -}}
{{end}} {{- end -}}
{{end}} {{- end -}}
{{end}} {{- end -}}
{{else}} {{- else -}}
{{if .IsClosed}} {{- if .IsClosed -}}
{{svg "octicon-issue-closed" 16 "text red"}} {{- svg "octicon-issue-closed" 16 "text red" -}}
{{else}} {{- else -}}
{{svg "octicon-issue-opened" 16 "text green"}} {{- svg "octicon-issue-opened" 16 "text green" -}}
{{end}} {{- end -}}
{{end}} {{- end -}}

View File

@ -58,7 +58,7 @@ func InitTest(requireGitea bool) {
_ = os.Setenv("GITEA_CONF", giteaConf) _ = os.Setenv("GITEA_CONF", giteaConf)
fmt.Printf("Environment variable $GITEA_CONF not set, use default: %s\n", giteaConf) fmt.Printf("Environment variable $GITEA_CONF not set, use default: %s\n", giteaConf)
if !setting.EnableSQLite3 { 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) { if !filepath.IsAbs(giteaConf) {