Use relative links for commits, mentions, and issues in markdown (#29427)
Fixes #29404 Use relative links for - commits - mentions - issues --------- Co-authored-by: silverwind <me@silverwind.io> (cherry picked from commit 85c59d6c21e10ef9d3ccf11713548f50e47e920f)
This commit is contained in:
parent
e2abfdc3a4
commit
024bfb7f34
9 changed files with 65 additions and 26 deletions
|
@ -609,7 +609,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
if ok && strings.Contains(mention, "/") {
|
if ok && strings.Contains(mention, "/") {
|
||||||
mentionOrgAndTeam := strings.Split(mention, "/")
|
mentionOrgAndTeam := strings.Split(mention, "/")
|
||||||
if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
|
if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
|
||||||
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(setting.AppURL, "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention"))
|
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention"))
|
||||||
node = node.NextSibling.NextSibling
|
node = node.NextSibling.NextSibling
|
||||||
start = 0
|
start = 0
|
||||||
continue
|
continue
|
||||||
|
@ -620,7 +620,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
mentionedUsername := mention[1:]
|
mentionedUsername := mention[1:]
|
||||||
|
|
||||||
if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
|
if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
|
||||||
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(setting.AppURL, mentionedUsername), mention, "mention"))
|
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention"))
|
||||||
node = node.NextSibling.NextSibling
|
node = node.NextSibling.NextSibling
|
||||||
} else {
|
} else {
|
||||||
node = node.NextSibling
|
node = node.NextSibling
|
||||||
|
@ -898,9 +898,9 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
path = "pulls"
|
path = "pulls"
|
||||||
}
|
}
|
||||||
if ref.Owner == "" {
|
if ref.Owner == "" {
|
||||||
link = createLink(util.URLJoin(setting.AppURL, ctx.Metas["user"], ctx.Metas["repo"], path, ref.Issue), reftext, "ref-issue")
|
link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], path, ref.Issue), reftext, "ref-issue")
|
||||||
} else {
|
} else {
|
||||||
link = createLink(util.URLJoin(setting.AppURL, ref.Owner, ref.Name, path, ref.Issue), reftext, "ref-issue")
|
link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, path, ref.Issue), reftext, "ref-issue")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -939,7 +939,7 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
}
|
}
|
||||||
|
|
||||||
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
|
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
|
||||||
link := createLink(util.URLJoin(setting.AppSubURL, ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
|
link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
|
||||||
|
|
||||||
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
|
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
|
||||||
node = node.NextSibling.NextSibling
|
node = node.NextSibling.NextSibling
|
||||||
|
@ -1166,7 +1166,7 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
link := util.URLJoin(setting.AppURL, ctx.Metas["user"], ctx.Metas["repo"], "commit", hash)
|
link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash)
|
||||||
replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
|
replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
|
||||||
start = 0
|
start = 0
|
||||||
node = node.NextSibling.NextSibling
|
node = node.NextSibling.NextSibling
|
||||||
|
|
|
@ -287,6 +287,7 @@ func TestRender_IssueIndexPattern_Document(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
|
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
|
||||||
|
ctx.Links.AbsolutePrefix = true
|
||||||
if ctx.Links.Base == "" {
|
if ctx.Links.Base == "" {
|
||||||
ctx.Links.Base = TestRepoURL
|
ctx.Links.Base = TestRepoURL
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ func TestRender_Commits(t *testing.T) {
|
||||||
Ctx: git.DefaultContext,
|
Ctx: git.DefaultContext,
|
||||||
RelativePath: ".md",
|
RelativePath: ".md",
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: markup.TestRepoURL,
|
Base: markup.TestRepoURL,
|
||||||
},
|
},
|
||||||
Metas: localMetas,
|
Metas: localMetas,
|
||||||
|
@ -96,6 +97,7 @@ func TestRender_CrossReferences(t *testing.T) {
|
||||||
Ctx: git.DefaultContext,
|
Ctx: git.DefaultContext,
|
||||||
RelativePath: "a.md",
|
RelativePath: "a.md",
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: setting.AppSubURL,
|
Base: setting.AppSubURL,
|
||||||
},
|
},
|
||||||
Metas: localMetas,
|
Metas: localMetas,
|
||||||
|
@ -579,6 +581,7 @@ func TestPostProcess_RenderDocument(t *testing.T) {
|
||||||
err := markup.PostProcess(&markup.RenderContext{
|
err := markup.PostProcess(&markup.RenderContext{
|
||||||
Ctx: git.DefaultContext,
|
Ctx: git.DefaultContext,
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: "https://example.com",
|
Base: "https://example.com",
|
||||||
},
|
},
|
||||||
Metas: localMetas,
|
Metas: localMetas,
|
||||||
|
|
|
@ -131,11 +131,11 @@ func testAnswers(baseURLContent, baseURLImages string) []string {
|
||||||
<li><a href="` + baseURLContent + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
<li><a href="` + baseURLContent + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||||
<li><a href="` + baseURLContent + `/Tips" rel="nofollow">Tips</a></li>
|
<li><a href="` + baseURLContent + `/Tips" rel="nofollow">Tips</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>See commit <a href="http://localhost:3000/gogits/gogs/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
|
<p>See commit <a href="/gogits/gogs/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
|
||||||
<p>Ideas and codes</p>
|
<p>Ideas and codes</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Bezier widget (by <a href="` + AppURL + `r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
|
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
|
||||||
<li>Bezier widget (by <a href="` + AppURL + `r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/gogits/gogs/issues/786" class="ref-issue" rel="nofollow">#786</a></li>
|
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/gogits/gogs/issues/786" class="ref-issue" rel="nofollow">#786</a></li>
|
||||||
<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
|
<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
|
||||||
<li><a href="` + baseURLContent + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
|
<li><a href="` + baseURLContent + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
|
||||||
<li><a href="` + baseURLContent + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
|
<li><a href="` + baseURLContent + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
|
||||||
|
|
|
@ -82,11 +82,19 @@ type RenderContext struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Links struct {
|
type Links struct {
|
||||||
|
AbsolutePrefix bool
|
||||||
Base string
|
Base string
|
||||||
BranchPath string
|
BranchPath string
|
||||||
TreePath string
|
TreePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Links) Prefix() string {
|
||||||
|
if l.AbsolutePrefix {
|
||||||
|
return setting.AppURL
|
||||||
|
}
|
||||||
|
return setting.AppSubURL
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Links) HasBranchInfo() bool {
|
func (l *Links) HasBranchInfo() bool {
|
||||||
return l.BranchPath != ""
|
return l.BranchPath != ""
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,21 +122,21 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a582
|
||||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||||
<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
|
<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
|
||||||
<a href="http://localhost:3000/mention-user" class="mention">@mention-user</a> test
|
<a href="/mention-user" class="mention">@mention-user</a> test
|
||||||
<a href="http://localhost:3000/user13/repo11/issues/123" class="ref-issue">#123</a>
|
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
||||||
space`
|
space`
|
||||||
|
|
||||||
assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
|
assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCommitMessage(t *testing.T) {
|
func TestRenderCommitMessage(t *testing.T) {
|
||||||
expected := `space <a href="http://localhost:3000/mention-user" class="mention">@mention-user</a> `
|
expected := `space <a href="/mention-user" class="mention">@mention-user</a> `
|
||||||
|
|
||||||
assert.EqualValues(t, expected, RenderCommitMessage(context.Background(), testInput, testMetas))
|
assert.EqualValues(t, expected, RenderCommitMessage(context.Background(), testInput, testMetas))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCommitMessageLinkSubject(t *testing.T) {
|
func TestRenderCommitMessageLinkSubject(t *testing.T) {
|
||||||
expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="http://localhost:3000/mention-user" class="mention">@mention-user</a>`
|
expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>`
|
||||||
|
|
||||||
assert.EqualValues(t, expected, RenderCommitMessageLinkSubject(context.Background(), testInput, "https://example.com/link", testMetas))
|
assert.EqualValues(t, expected, RenderCommitMessageLinkSubject(context.Background(), testInput, "https://example.com/link", testMetas))
|
||||||
}
|
}
|
||||||
|
@ -160,14 +160,14 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||||
mail@domain.com
|
mail@domain.com
|
||||||
@mention-user test
|
@mention-user test
|
||||||
<a href="http://localhost:3000/user13/repo11/issues/123" class="ref-issue">#123</a>
|
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
|
||||||
space
|
space
|
||||||
`
|
`
|
||||||
assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
|
assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderMarkdownToHtml(t *testing.T) {
|
func TestRenderMarkdownToHtml(t *testing.T) {
|
||||||
expected := `<p>space <a href="http://localhost:3000/mention-user" rel="nofollow">@mention-user</a><br/>
|
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
|
||||||
/just/a/path.bin
|
/just/a/path.bin
|
||||||
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
|
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
|
||||||
<a href="/file.bin" rel="nofollow">local link</a>
|
<a href="/file.bin" rel="nofollow">local link</a>
|
||||||
|
@ -184,7 +184,7 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a582
|
||||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||||
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
||||||
<a href="http://localhost:3000/mention-user" rel="nofollow">@mention-user</a> test
|
<a href="/mention-user" rel="nofollow">@mention-user</a> test
|
||||||
#123
|
#123
|
||||||
space</p>
|
space</p>
|
||||||
`
|
`
|
||||||
|
|
|
@ -34,6 +34,7 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr
|
||||||
if err := markdown.RenderRaw(&markup.RenderContext{
|
if err := markdown.RenderRaw(&markup.RenderContext{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: urlPrefix,
|
Base: urlPrefix,
|
||||||
},
|
},
|
||||||
}, strings.NewReader(text), ctx.Resp); err != nil {
|
}, strings.NewReader(text), ctx.Resp); err != nil {
|
||||||
|
@ -79,6 +80,7 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr
|
||||||
if err := markup.Render(&markup.RenderContext{
|
if err := markup.Render(&markup.RenderContext{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: urlPrefix,
|
Base: urlPrefix,
|
||||||
},
|
},
|
||||||
Metas: meta,
|
Metas: meta,
|
||||||
|
|
|
@ -222,6 +222,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
|
||||||
body, err := markdown.RenderString(&markup.RenderContext{
|
body, err := markdown.RenderString(&markup.RenderContext{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Links: markup.Links{
|
Links: markup.Links{
|
||||||
|
AbsolutePrefix: true,
|
||||||
Base: ctx.Issue.Repo.HTMLURL(),
|
Base: ctx.Issue.Repo.HTMLURL(),
|
||||||
},
|
},
|
||||||
Metas: ctx.Issue.Repo.ComposeMetas(ctx),
|
Metas: ctx.Issue.Repo.ComposeMetas(ctx),
|
||||||
|
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"mime/quotedprintable"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -19,6 +21,7 @@ import (
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/markup"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -67,6 +70,12 @@ func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Re
|
||||||
func TestComposeIssueCommentMessage(t *testing.T) {
|
func TestComposeIssueCommentMessage(t *testing.T) {
|
||||||
doer, _, issue, comment := prepareMailerTest(t)
|
doer, _, issue, comment := prepareMailerTest(t)
|
||||||
|
|
||||||
|
markup.Init(&markup.ProcessorHelper{
|
||||||
|
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||||
|
return username == doer.Name
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
setting.IncomingEmail.Enabled = true
|
setting.IncomingEmail.Enabled = true
|
||||||
defer func() { setting.IncomingEmail.Enabled = false }()
|
defer func() { setting.IncomingEmail.Enabled = false }()
|
||||||
|
|
||||||
|
@ -77,7 +86,8 @@ func TestComposeIssueCommentMessage(t *testing.T) {
|
||||||
msgs, err := composeIssueCommentMessages(&mailCommentContext{
|
msgs, err := composeIssueCommentMessages(&mailCommentContext{
|
||||||
Context: context.TODO(), // TODO: use a correct context
|
Context: context.TODO(), // TODO: use a correct context
|
||||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
|
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
|
||||||
Content: "test body", Comment: comment,
|
Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
|
||||||
|
Comment: comment,
|
||||||
}, "en-US", recipients, false, "issue comment")
|
}, "en-US", recipients, false, "issue comment")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, msgs, 2)
|
assert.Len(t, msgs, 2)
|
||||||
|
@ -96,6 +106,20 @@ func TestComposeIssueCommentMessage(t *testing.T) {
|
||||||
assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match")
|
assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match")
|
||||||
assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0])
|
assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0])
|
||||||
assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto
|
assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gomailMsg.WriteTo(&buf)
|
||||||
|
|
||||||
|
b, err := io.ReadAll(quotedprintable.NewReader(&buf))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// text/plain
|
||||||
|
assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, doer.HTMLURL()))
|
||||||
|
assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, issue.HTMLURL()))
|
||||||
|
|
||||||
|
// text/html
|
||||||
|
assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, doer.HTMLURL()))
|
||||||
|
assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComposeIssueMessage(t *testing.T) {
|
func TestComposeIssueMessage(t *testing.T) {
|
||||||
|
|
Loading…
Reference in a new issue