diff --git a/models/activities/action.go b/models/activities/action.go index 8e589eda88d90..942ddd1cd1dbd 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -372,6 +372,18 @@ func (a *Action) IsIssueEvent() bool { return a.OpType.InActions("comment_issue", "approve_pull_request", "reject_pull_request", "comment_pull", "merge_pull_request") } +// GetIssueContentBody returns the comment body from the action content. +// Unlike GetIssueInfos which splits on "|" into 3 parts, this only splits on +// the first "|" to preserve any "|" characters within the comment body +// (e.g. Mermaid diagram syntax like "A -->|text| B"). +func (a *Action) GetIssueContentBody() string { + parts := strings.SplitN(a.Content, "|", 2) + if len(parts) < 2 { + return "" + } + return parts[1] +} + // GetIssueInfos returns a list of associated information with the action. func (a *Action) GetIssueInfos() []string { // make sure it always returns 3 elements, because there are some access to the a[1] and a[2] without checking the length diff --git a/models/activities/action_test.go b/models/activities/action_test.go index 9447f39d62a1a..a91a81325be69 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -125,6 +125,24 @@ func TestConsistencyUpdateAction(t *testing.T) { unittest.CheckConsistencyFor(t, &activities_model.Action{}) } +func TestGetIssueContentBody(t *testing.T) { + tests := []struct { + content string + expected string + }{ + {content: "1|simple body", expected: "simple body"}, + {content: "1|A -->|text| B", expected: "A -->|text| B"}, + {content: "1|first|second|third", expected: "first|second|third"}, + {content: "1|", expected: ""}, + {content: "no-delimiter", expected: ""}, + {content: "", expected: ""}, + } + for _, test := range tests { + action := &activities_model.Action{Content: test.content} + assert.Equal(t, test.expected, action.GetIssueContentBody(), "content: %q", test.content) + } +} + func TestDeleteIssueActions(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/services/feed/notifier.go b/services/feed/notifier.go index 64aeccdfd227b..0acc0c59e10eb 100644 --- a/services/feed/notifier.go +++ b/services/feed/notifier.go @@ -25,6 +25,41 @@ type actionNotifier struct { notify_service.NullNotifier } +// trimUnclosedCodeBlock removes a trailing unclosed fenced code block from a +// truncated string. When content is cut at an arbitrary character limit, a +// fenced code block (``` ...) may be left open, producing invalid Markdown that +// the renderer tries (and fails) to process. This function detects the +// situation and strips the partial block. +func trimUnclosedCodeBlock(s string) string { + inBlock := false + lastOpenIdx := -1 + i := 0 + for i < len(s) { + lineStart := i + lineEnd := strings.Index(s[i:], "\n") + var line string + if lineEnd == -1 { + line = s[i:] + i = len(s) + } else { + line = s[i : i+lineEnd] + i = i + lineEnd + 1 + } + if strings.HasPrefix(strings.TrimLeft(line, " "), "```") { + if !inBlock { + lastOpenIdx = lineStart + inBlock = true + } else { + inBlock = false + } + } + } + if inBlock && lastOpenIdx >= 0 { + return strings.TrimRight(s[:lastOpenIdx], " \t\n") + } + return s +} + var _ notify_service.Notifier = &actionNotifier{} func Init() error { @@ -117,6 +152,9 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode truncatedContent = truncatedContent[:lastSpaceIdx] + "…" } } + if truncatedRight != "" { + truncatedContent = trimUnclosedCodeBlock(truncatedContent) + } act.Content = fmt.Sprintf("%d|%s", issue.Index, truncatedContent) if issue.IsPull { diff --git a/services/feed/notifier_test.go b/services/feed/notifier_test.go index ef70facd1e429..27a6501d6531e 100644 --- a/services/feed/notifier_test.go +++ b/services/feed/notifier_test.go @@ -22,6 +22,55 @@ func TestMain(m *testing.M) { unittest.MainTest(m) } +func TestTrimUnclosedCodeBlock(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no code block", + input: "hello world", + expected: "hello world", + }, + { + name: "closed code block", + input: "before\n```go\nfmt.Println()\n```\nafter", + expected: "before\n```go\nfmt.Println()\n```\nafter", + }, + { + name: "unclosed code block", + input: "before\n```mermaid\ngraph LR\nA --> B", + expected: "before", + }, + { + name: "unclosed code block with leading text", + input: "some text here\n```\ncode line 1\ncode line 2", + expected: "some text here", + }, + { + name: "closed then unclosed", + input: "```\nblock1\n```\ntext\n```\nunclosed", + expected: "```\nblock1\n```\ntext", + }, + { + name: "only unclosed fence", + input: "```mermaid\ngraph LR", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, trimUnclosedCodeBlock(tt.input)) + }) + } +} + func TestRenameRepoAction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index c3c042dc38776..17e6a4cfb9d48 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -108,7 +108,7 @@ {{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}} {{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}} {{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}} - {{$comment := index .GetIssueInfos 1}} + {{$comment := .GetIssueContentBody}} {{if $comment}}
{{ctx.RenderUtils.MarkdownToHtml $comment}}
{{end}}