Monorepo for Tangled tangled.org

pulls: render trailing git trailers as separate lines in pull body markdown #1128

open opened by nolith.dev targeting master from nolith.dev/tangled-core: pulls-fix-git-trailers

Pull descriptions ending with git trailers (for example Co-authored-by, Reviewed-by, Ticket) were rendered as a single wrapped paragraph because markdown treats single newlines as soft breaks. Normalize only trailing trailer blocks in pull bodies by converting trailer line separators to markdown hard line breaks (" \n"), then render through the existing markdown and sanitizer pipeline.

This keeps normal markdown behavior unchanged and avoids custom HTML rendering logic. The pull header template now uses markdownPullBody, and tests cover trailer normalization plus unchanged non-trailer cases.

AI-assisted: OpenCode (Openai GPT-5.3 Codex) Signed-off-by: Alessio Caiazza code.git@caiazza.info

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:nzep3slobztdph3kxswzbing/sh.tangled.repo.pull/3mgnw3arzwg22
+101 -6
Diff #0
+62 -5
appview/pages/funcmap.go
··· 16 16 "net/url" 17 17 "path/filepath" 18 18 "reflect" 19 + "regexp" 19 20 "strings" 20 21 "time" 21 22 ··· 34 35 "tangled.org/core/crypto" 35 36 ) 36 37 38 + const markdownHardLineBreak = " \n" 39 + 37 40 type tab map[string]string 38 41 39 42 func (p *Pages) funcMap() template.FuncMap { 43 + markdown := func(text string) template.HTML { 44 + p.rctx.RendererType = markup.RendererTypeDefault 45 + htmlString := p.rctx.RenderMarkdown(text) 46 + sanitized := p.rctx.SanitizeDefault(htmlString) 47 + return template.HTML(sanitized) 48 + } 49 + 40 50 return template.FuncMap{ 41 51 "split": func(s string) []string { 42 52 return strings.Split(s, "\n") ··· 261 271 } 262 272 return v.Slice(0, min(n, v.Len())).Interface() 263 273 }, 264 - "markdown": func(text string) template.HTML { 265 - p.rctx.RendererType = markup.RendererTypeDefault 266 - htmlString := p.rctx.RenderMarkdown(text) 267 - sanitized := p.rctx.SanitizeDefault(htmlString) 268 - return template.HTML(sanitized) 274 + "markdown": markdown, 275 + "markdownPullBody": func(text string) template.HTML { 276 + text = normalizePullBodyTrailers(text) 277 + return markdown(text) 269 278 }, 270 279 "description": func(text string) template.HTML { 271 280 p.rctx.RendererType = markup.RendererTypeDefault ··· 584 593 585 594 return strings.Join(parts, " ") 586 595 } 596 + 597 + var gitTrailersRegex = regexp.MustCompile("" + 598 + // dot-all: let . match \n 599 + `(?s)` + 600 + // blank line separating body from trailers 601 + `\n\n` + 602 + // capture group 1: entire trailer block 603 + `(` + 604 + // one trailer line: 605 + `(?:` + 606 + // key starts with alphanumeric 607 + `[A-Za-z0-9]` + 608 + // key continues with alphanumeric or hyphens 609 + `[A-Za-z0-9-]*` + 610 + // colon + whitespace 611 + `:\s+` + 612 + // at least one non-whitespace (value not empty) 613 + `\S` + 614 + // rest of the value 615 + `.*` + 616 + // line ends with newline or end-of-string 617 + `(?:\n|$)` + 618 + // one or more trailer lines 619 + `)+` + 620 + // end capture group 1 621 + `)` + 622 + // optional trailing whitespace 623 + `[ \t\r\n]*` + 624 + // must be at end of string 625 + `$`, 626 + ) 627 + 628 + func normalizePullBodyTrailers(text string) string { 629 + normalized := strings.ReplaceAll(text, "\r\n", "\n") 630 + match := gitTrailersRegex.FindStringSubmatchIndex(normalized) 631 + if len(match) < 4 { 632 + return text 633 + } 634 + 635 + mainBody := strings.TrimRight(normalized[:match[0]], "\n") 636 + rawTrailerBlock := strings.TrimSpace(normalized[match[2]:match[3]]) 637 + if rawTrailerBlock == "" { 638 + return text 639 + } 640 + 641 + trailerBlock := strings.ReplaceAll(rawTrailerBlock, "\n", markdownHardLineBreak) 642 + return mainBody + "\n\n" + trailerBlock 643 + }
+38
appview/pages/funcmap_trailers_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestNormalizePullBodyTrailers(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + input string 11 + want string 12 + }{ 13 + { 14 + name: "trailing trailers are normalized", 15 + input: "This is a placeholder commit message with multiple paragraphs\n\nThe content does not matter.\n\nCo-authored-by: Developer One <dev1@example.com>\nReviewed-by: Reviewer Two <reviewer@example.com>\nTicket: PROJ-123\n", 16 + want: "This is a placeholder commit message with multiple paragraphs\n\nThe content does not matter.\n\nCo-authored-by: Developer One <dev1@example.com> \nReviewed-by: Reviewer Two <reviewer@example.com> \nTicket: PROJ-123", 17 + }, 18 + { 19 + name: "no blank line separator means unchanged", 20 + input: "Body\nTicket: PROJ-123", 21 + want: "Body\nTicket: PROJ-123", 22 + }, 23 + { 24 + name: "regular prose with colons is unchanged", 25 + input: "Body\n\nThis line: has extra words and should not match", 26 + want: "Body\n\nThis line: has extra words and should not match", 27 + }, 28 + } 29 + 30 + for _, tt := range tests { 31 + t.Run(tt.name, func(t *testing.T) { 32 + got := normalizePullBodyTrailers(tt.input) 33 + if got != tt.want { 34 + t.Fatalf("normalized body = %q, want %q", got, tt.want) 35 + } 36 + }) 37 + } 38 + }
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 59 59 60 60 {{ if .Pull.Body }} 61 61 <article id="body" class="mt-8 prose dark:prose-invert"> 62 - {{ .Pull.Body | markdown }} 62 + {{ .Pull.Body | markdownPullBody }} 63 63 </article> 64 64 {{ end }} 65 65

History

2 rounds 4 comments
sign up or login to add to the discussion
1 commit
expand
pulls: normalize auto-generated trailer blocks in PR bodies
no conflicts, ready to merge
expand 1 comment

Unfortunately the pull description is still the old one :(

nolith.dev submitted #0
1 commit
expand
pulls: render trailing git trailers as separate lines in pull body markdown
expand 3 comments

The pull body shows exactly the problem I'm fixing.

gitTrailersRegex could be written as online, but even the complexity of the regex I went for a documented multiline version of it.

Thanks for the PR! Can we add that extra newline on auto PR description creation? Then we won't need special markdownPullBody.

Ideally I think we shouldn't even try to parse comment body as markdown, but that's another story (working on it!)

sure, I went for rendering time to fix the existing pulls too, but either options are fine.