fix(ui): keep actions run title intact when subject contains an issue ref (#38005)

This commit is contained in:
bircni
2026-06-06 11:00:14 +02:00
committed by GitHub
parent 3659b5acc2
commit 4088d7e241
8 changed files with 69 additions and 105 deletions
+2 -6
View File
@@ -23,6 +23,7 @@ import (
"gitea.dev/modules/base" "gitea.dev/modules/base"
"gitea.dev/modules/git" "gitea.dev/modules/git"
giturl "gitea.dev/modules/git/url" giturl "gitea.dev/modules/git/url"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
@@ -641,12 +642,7 @@ func (repo *Repository) CanContentChange() bool {
// DescriptionHTML does special handles to description and return HTML string. // DescriptionHTML does special handles to description and return HTML string.
func (repo *Repository) DescriptionHTML(ctx context.Context) template.HTML { func (repo *Repository) DescriptionHTML(ctx context.Context) template.HTML {
desc, err := markup.PostProcessDescriptionHTML(markup.NewRenderContext(ctx), repo.Description) return markup.PostProcessDescriptionHTML(markup.NewRenderContext(ctx), htmlutil.EscapeString(repo.Description))
if err != nil {
log.Error("Failed to render description for %s (ID: %d): %v", repo.Name, repo.ID, err)
return template.HTML(markup.SanitizeDescription(repo.Description))
}
return template.HTML(markup.SanitizeDescription(desc))
} }
// CloneLink represents different types of clone URLs of repository. // CloneLink represents different types of clone URLs of repository.
+22 -20
View File
@@ -14,8 +14,10 @@ import (
"sync" "sync"
"gitea.dev/modules/htmlutil" "gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/common" "gitea.dev/modules/markup/common"
"gitea.dev/modules/translation" "gitea.dev/modules/translation"
"gitea.dev/modules/util"
"golang.org/x/net/html" "golang.org/x/net/html"
"golang.org/x/net/html/atom" "golang.org/x/net/html/atom"
@@ -151,8 +153,7 @@ func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) e
} }
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor. // PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor.
// FIXME: this function and its family have a very strange design: it takes HTML as input and output, processes the "escaped" content. func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) template.HTML {
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (template.HTML, error) {
procs := []processor{ procs := []processor{
fullIssuePatternProcessor, fullIssuePatternProcessor,
comparePatternProcessor, comparePatternProcessor,
@@ -166,8 +167,7 @@ func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (templa
emojiProcessor, emojiProcessor,
emojiShortCodeProcessor, emojiShortCodeProcessor,
} }
s, err := postProcessString(ctx, procs, string(content)) return postProcessHTML(ctx, procs, content)
return template.HTML(s), err
} }
var emojiProcessors = []processor{ var emojiProcessors = []processor{
@@ -189,7 +189,7 @@ func isBareURLSubject(content string) bool {
// PostProcessCommitMessageSubject will use the same logic as PostProcess and // PostProcessCommitMessageSubject will use the same logic as PostProcess and
// PostProcessCommitMessage, but will disable the shortLinkProcessor and // PostProcessCommitMessage, but will disable the shortLinkProcessor and
// emailAddressProcessor, and wraps the whole subject in defaultLink. // emailAddressProcessor, and wraps the whole subject in defaultLink.
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) { func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, content template.HTML) template.HTML {
procs := []processor{ procs := []processor{
fullIssuePatternProcessor, fullIssuePatternProcessor,
comparePatternProcessor, comparePatternProcessor,
@@ -207,7 +207,7 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
// plain text inside defaultLink. Partial URLs inside larger text still become // plain text inside defaultLink. Partial URLs inside larger text still become
// their own links (nested anchors aren't legal HTML, so the outer defaultLink // their own links (nested anchors aren't legal HTML, so the outer defaultLink
// naturally breaks on that span, same as on GitHub). // naturally breaks on that span, same as on GitHub).
if !isBareURLSubject(content) { if !isBareURLSubject(string(content)) {
procs = append(procs, linkProcessor) procs = append(procs, linkProcessor)
} }
procs = append(procs, func(ctx *RenderContext, node *html.Node) { procs = append(procs, func(ctx *RenderContext, node *html.Node) {
@@ -215,27 +215,28 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
node.Type = html.ElementNode node.Type = html.ElementNode
node.Data = "a" node.Data = "a"
node.DataAtom = atom.A node.DataAtom = atom.A
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}} node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
node.FirstChild, node.LastChild = ch, ch node.FirstChild, node.LastChild = ch, ch
}) })
return postProcessString(ctx, procs, content) rendered := postProcessHTML(ctx, procs, content)
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
} }
// PostProcessIssueTitle to process title on individual issue/pull page // PostProcessIssueTitle to process title on individual issue/pull page
func PostProcessIssueTitle(ctx *RenderContext, title string) (string, error) { func PostProcessIssueTitle(ctx *RenderContext, titleHTML template.HTML) template.HTML {
return postProcessString(ctx, []processor{ return postProcessHTML(ctx, []processor{
issueIndexPatternProcessor, issueIndexPatternProcessor,
commitCrossReferencePatternProcessor, commitCrossReferencePatternProcessor,
hashCurrentPatternProcessor, hashCurrentPatternProcessor,
emojiShortCodeProcessor, emojiShortCodeProcessor,
emojiProcessor, emojiProcessor,
}, title) }, titleHTML)
} }
// PostProcessDescriptionHTML will use similar logic as PostProcess, but will // PostProcessDescriptionHTML will use similar logic as PostProcess, but will
// use a single special linkProcessor. // use a single special linkProcessor.
func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, error) { func PostProcessDescriptionHTML(ctx *RenderContext, content template.HTML) template.HTML {
return postProcessString(ctx, []processor{ return postProcessHTML(ctx, []processor{
descriptionLinkProcessor, descriptionLinkProcessor,
emojiShortCodeProcessor, emojiShortCodeProcessor,
emojiProcessor, emojiProcessor,
@@ -243,17 +244,18 @@ func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, err
} }
// PostProcessEmoji for when we want to just process emoji and shortcodes // PostProcessEmoji for when we want to just process emoji and shortcodes
// in various places it isn't already run through the normal markdown processor // in various places it isn't already run through the normal Markdown processor
func PostProcessEmoji(ctx *RenderContext, content string) (string, error) { func PostProcessEmoji(ctx *RenderContext, content template.HTML) template.HTML {
return postProcessString(ctx, emojiProcessors, content) return postProcessHTML(ctx, emojiProcessors, content)
} }
func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { func postProcessHTML(ctx *RenderContext, procs []processor, content template.HTML) template.HTML {
var buf strings.Builder var buf strings.Builder
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { if err := postProcess(ctx, procs, strings.NewReader(string(content)), &buf); err != nil {
return "", err log.Warn("postProcessHTML err: %v, input: %s", err, util.TruncateRunes(string(content), 200))
return content
} }
return buf.String(), nil return template.HTML(buf.String())
} }
func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) { func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) {
+3 -3
View File
@@ -5,6 +5,7 @@ package markup
import ( import (
"fmt" "fmt"
"html/template"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -260,9 +261,8 @@ func TestRender_PostProcessIssueTitle(t *testing.T) {
"repo": "someRepo", "repo": "someRepo",
"style": IssueNameStyleNumeric, "style": IssueNameStyleNumeric,
} }
actual, err := PostProcessIssueTitle(NewTestRenderContext(metas), "#1") actual := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
assert.NoError(t, err) assert.Equal(t, template.HTML("#1"), actual)
assert.Equal(t, "#1", actual)
} }
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) { func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
+19 -60
View File
@@ -11,7 +11,6 @@ import (
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"unicode"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper" "gitea.dev/models/renderhelper"
@@ -39,60 +38,35 @@ func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
return &RenderUtils{ctx: ctx} return &RenderUtils{ctx: ctx}
} }
// RenderCommitMessage renders commit message with XSS-safe and special links. // RenderCommitMessage renders commit message title (only title)
func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML { func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML {
cleanMsg := template.HTML(template.HTMLEscapeString(msg)) msgLine := strings.TrimSpace(msg)
// we can safely assume that it will not return any error, since there shouldn't be any special HTML. msgLine, _, _ = strings.Cut(msgLine, "\n")
// "repo" can be nil when rendering commit messages for deleted repositories in a user's dashboard feed. msgLine = strings.TrimSpace(msgLine)
fullMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), cleanMsg) rendered := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlutil.EscapeString(msgLine))
if err != nil { return renderCodeBlock(rendered)
log.Error("PostProcessCommitMessage: %v", err)
return ""
}
msgLines := strings.Split(strings.TrimSpace(string(fullMessage)), "\n")
if len(msgLines) == 0 {
return ""
}
return renderCodeBlock(template.HTML(msgLines[0]))
} }
// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to // RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
// the provided default url, handling for special links without email to links. // the provided default url, handling for special links without email to links.
func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML { func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML {
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace) msgLine := strings.TrimSpace(msg)
lineEnd := strings.IndexByte(msgLine, '\n') msgLine, _, _ = strings.Cut(msgLine, "\n")
if lineEnd > 0 { msgLine = strings.TrimSpace(msgLine)
msgLine = msgLine[:lineEnd] rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
} rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, htmlutil.EscapeString(msgLine))
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) return renderCodeBlock(rendered)
if len(msgLine) == 0 {
return ""
}
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
renderedMessage, err := markup.PostProcessCommitMessageSubject(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), urlDefault, template.HTMLEscapeString(msgLine))
if err != nil {
log.Error("PostProcessCommitMessageSubject: %v", err)
return ""
}
return renderCodeBlock(template.HTML(renderedMessage))
} }
// RenderCommitBody extracts the body of a commit message without its title. // RenderCommitBody extracts the body of a commit message without its title.
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML { func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n") _, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
body = strings.TrimFunc(body, unicode.IsSpace) body = strings.TrimSpace(body)
if body == "" { if body == "" {
return "" return ""
} }
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo) rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
htmlContent := template.HTML(template.HTMLEscapeString(body)) renderedMessage := markup.PostProcessCommitMessage(rctx, htmlutil.EscapeString(body))
renderedMessage, err := markup.PostProcessCommitMessage(rctx, htmlContent)
if err != nil {
log.Error("PostProcessCommitMessage: %v", err)
return ""
}
return renderedMessage return renderedMessage
} }
@@ -108,25 +82,15 @@ func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
// RenderIssueTitle renders issue/pull title with defined post processors // RenderIssueTitle renders issue/pull title with defined post processors
func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML { func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML {
// wrap "`…`" in <code> before post-processing so code-span content stays literal, like comment bodies // wrap "`…`" in <code> before post-processing so code-span content stays literal, like comment bodies
htmlWithCode := renderCodeBlock(template.HTML(template.HTMLEscapeString(text))) htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
renderedText, err := markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), string(htmlWithCode)) return markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlWithCode)
if err != nil {
log.Error("PostProcessIssueTitle: %v", err)
return ""
}
return template.HTML(renderedText)
} }
// RenderIssueSimpleTitle only renders with emoji and inline code block // RenderIssueSimpleTitle only renders with emoji and inline code block
func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML { func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
// see RenderIssueTitle: wrap code spans before processing emoji // see RenderIssueTitle: wrap code spans before processing emoji
htmlWithCode := renderCodeBlock(template.HTML(template.HTMLEscapeString(text))) htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), string(htmlWithCode)) return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlWithCode)
if err != nil {
log.Error("RenderIssueSimpleTitle: %v", err)
return ""
}
return template.HTML(renderedText)
} }
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML { func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
@@ -202,12 +166,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
// RenderEmoji renders html text with emoji post processors // RenderEmoji renders html text with emoji post processors
func (ut *RenderUtils) RenderEmoji(text string) template.HTML { func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text)) return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlutil.EscapeString(text))
if err != nil {
log.Error("RenderEmoji: %v", err)
return ""
}
return template.HTML(renderedText)
} }
// reactionToEmoji renders emoji for use in reactions // reactionToEmoji renders emoji for use in reactions
+4 -4
View File
@@ -131,24 +131,24 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
}) })
t.Run("RenderCommitMessage", func(t *testing.T) { t.Run("RenderCommitMessage", func(t *testing.T) {
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> ` expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo))
}) })
t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) { t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) {
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>` expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a></span>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
}) })
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) { t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
// a bare URL in the subject must not hijack the default link // a bare URL in the subject must not hijack the default link
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>` expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">https://example.com/file.bin</a></span>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
}) })
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) { t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
// a URL embedded in larger subject text still becomes its own link // a URL embedded in larger subject text still becomes its own link
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>` expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted title-full-link"> here</a></span>`
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
}) })
+1 -5
View File
@@ -414,11 +414,7 @@ func Diff(ctx *context.Context) {
ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)}) rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, htmlMessage) ctx.Data["NoteRendered"] = markup.PostProcessCommitMessage(rctx, htmlMessage)
if err != nil {
ctx.ServerError("PostProcessCommitMessage", err)
return
}
} else if !git.IsErrNotExist(err) { } else if !git.IsErrNotExist(err) {
log.Error("GetNote: %v", err) log.Error("GetNote: %v", err)
} }
+4 -7
View File
@@ -13,13 +13,10 @@
</span> </span>
</div> </div>
<div class="item-main"> <div class="item-main">
<span class="item-title" title="{{$run.Title}}"> <div class="item-title">
{{if $run.Title}} {{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $run.Title $run.Link $.Repository}} {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}}
{{else}} </div>
<a href="{{$run.Link}}">{{ctx.Locale.Tr "actions.runs.empty_commit_message"}}</a>
{{end}}
</span>
<div class="item-body"> <div class="item-body">
{{$workflowName := index $.WorkflowNames $run.WorkflowID}} {{$workflowName := index $.WorkflowNames $run.WorkflowID}}
<span><b>{{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span> <span><b>{{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
+14
View File
@@ -92,3 +92,17 @@
margin-right: 8px; margin-right: 8px;
text-align: left; text-align: left;
} }
/* for "title (#123)":
<span "title-full-link-hover">
<a "title-full-link muted">title (</a>
<a "muted">#123</a>
<a "title-full-link muted">)</a>
</span>
* hover on "title": also highlight the right parentheses
* hover on "#123": don't highlight other parts
*/
.title-full-link-hover:not(:has(:not(.title-full-link):hover)):hover > a.title-full-link {
color: var(--color-primary);
text-decoration: underline;
}