Skip to content

Commit 562c0e6

Browse files
committed
feat(submit): unwrap hard line breaks in generated PR descriptions
Commit message bodies are typically hard-wrapped at ~72 columns, but GitHub renders single newlines as `<br>` in PR descriptions, resulting in ugly narrow paragraphs. `generatePRBody` now unwraps paragraph lines while preserving markdown structure (code blocks, lists, headers, blockquotes, tables, horizontal rules). If HTML tags are detected, the body is left as-is to avoid mangling intentional formatting.
1 parent f23002e commit 562c0e6

2 files changed

Lines changed: 388 additions & 2 deletions

File tree

cmd/submit.go

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package cmd
44
import (
55
"fmt"
66
"os"
7+
"regexp"
78
"strings"
89

910
"github.com/boneskull/gh-stack/internal/config"
@@ -514,6 +515,10 @@ func promptMarkPRReady(ghClient *github.Client, prNumber int, branch, trunk stri
514515
// generatePRBody creates a PR description from the commits between base and head.
515516
// For a single commit: returns the commit body.
516517
// For multiple commits: returns each commit as a markdown section.
518+
//
519+
// Commit message bodies are unwrapped so that hard line breaks within paragraphs
520+
// (typical of the ~72-column git convention) are removed. This produces better
521+
// rendering in GitHub's PR description, which treats single newlines as <br> tags.
517522
func generatePRBody(g *git.Git, base, head string) (string, error) {
518523
commits, err := g.GetCommits(base, head)
519524
if err != nil {
@@ -526,7 +531,7 @@ func generatePRBody(g *git.Git, base, head string) (string, error) {
526531

527532
if len(commits) == 1 {
528533
// Single commit: just use the body
529-
return commits[0].Body, nil
534+
return unwrapParagraphs(commits[0].Body), nil
530535
}
531536

532537
// Multiple commits: format as markdown sections
@@ -540,10 +545,190 @@ func generatePRBody(g *git.Git, base, head string) (string, error) {
540545
sb.WriteString("\n")
541546
if commit.Body != "" {
542547
sb.WriteString("\n")
543-
sb.WriteString(commit.Body)
548+
sb.WriteString(unwrapParagraphs(commit.Body))
544549
sb.WriteString("\n")
545550
}
546551
}
547552

548553
return sb.String(), nil
549554
}
555+
556+
// unwrapParagraphs removes hard line breaks within plain-text paragraphs while
557+
// preserving intentional structure: blank lines, markdown block-level syntax
558+
// (headers, lists, blockquotes, horizontal rules), and code blocks (both fenced
559+
// and indented). This converts the ~72-column convention used in commit messages
560+
// into flowing text suitable for GitHub's markdown renderer.
561+
//
562+
// If HTML tags are found in prose (outside code blocks and inline code spans),
563+
// the entire text is returned as-is — anyone writing raw HTML in a commit message
564+
// is doing something intentional with formatting.
565+
566+
// htmlTagRe matches anything that looks like an HTML tag (e.g. <div>, </span>, <br/>).
567+
var htmlTagRe = regexp.MustCompile(`</?[a-zA-Z][a-zA-Z0-9]*[\s/>]`)
568+
569+
// inlineCodeRe matches backtick-enclosed inline code spans so we can strip them
570+
// before checking for HTML. Otherwise `<token>` in code would trigger a false positive.
571+
var inlineCodeRe = regexp.MustCompile("`[^`]+`")
572+
573+
// containsHTMLOutsideCode scans the text for HTML tags that appear in prose,
574+
// ignoring content inside fenced code blocks, indented code blocks, and inline
575+
// code spans. Returns true if HTML is found in any prose line.
576+
func containsHTMLOutsideCode(text string) bool {
577+
lines := strings.Split(text, "\n")
578+
inFencedCodeBlock := false
579+
580+
for _, line := range lines {
581+
trimmed := strings.TrimRight(line, " \t")
582+
583+
// Track fenced code blocks (``` or ~~~)
584+
if !inFencedCodeBlock && (strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~")) {
585+
inFencedCodeBlock = true
586+
continue
587+
}
588+
if inFencedCodeBlock {
589+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
590+
inFencedCodeBlock = false
591+
}
592+
continue
593+
}
594+
595+
// Skip indented code blocks (4+ spaces or tab)
596+
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
597+
continue
598+
}
599+
600+
// Strip inline code spans, then check for HTML
601+
stripped := inlineCodeRe.ReplaceAllString(line, "")
602+
if htmlTagRe.MatchString(stripped) {
603+
return true
604+
}
605+
}
606+
607+
return false
608+
}
609+
610+
func unwrapParagraphs(text string) string {
611+
if text == "" {
612+
return ""
613+
}
614+
615+
// Bail if the text contains HTML tags in prose — don't mess with it.
616+
if containsHTMLOutsideCode(text) {
617+
return text
618+
}
619+
620+
lines := strings.Split(text, "\n")
621+
var result []string
622+
var paragraph []string
623+
inFencedCodeBlock := false
624+
625+
flushParagraph := func() {
626+
if len(paragraph) > 0 {
627+
result = append(result, strings.Join(paragraph, " "))
628+
paragraph = nil
629+
}
630+
}
631+
632+
for _, line := range lines {
633+
trimmed := strings.TrimRight(line, " \t")
634+
635+
// Track fenced code blocks (``` or ~~~)
636+
if !inFencedCodeBlock && (strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~")) {
637+
flushParagraph()
638+
result = append(result, line)
639+
inFencedCodeBlock = true
640+
continue
641+
}
642+
if inFencedCodeBlock {
643+
result = append(result, line)
644+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
645+
inFencedCodeBlock = false
646+
}
647+
continue
648+
}
649+
650+
// Blank line = paragraph break
651+
if trimmed == "" {
652+
flushParagraph()
653+
result = append(result, "")
654+
continue
655+
}
656+
657+
// Preserve lines that are markdown block-level elements
658+
if isBlockElement(trimmed) {
659+
flushParagraph()
660+
result = append(result, line)
661+
continue
662+
}
663+
664+
// Indented code block (4+ spaces or tab)
665+
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
666+
flushParagraph()
667+
result = append(result, line)
668+
continue
669+
}
670+
671+
// Otherwise it's a paragraph line — accumulate it
672+
paragraph = append(paragraph, trimmed)
673+
}
674+
675+
flushParagraph()
676+
677+
return strings.Join(result, "\n")
678+
}
679+
680+
// isBlockElement returns true if the line starts with markdown block-level syntax
681+
// that should not be joined with adjacent lines.
682+
func isBlockElement(line string) bool {
683+
// Headers
684+
if strings.HasPrefix(line, "#") {
685+
return true
686+
}
687+
// Unordered lists
688+
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ") ||
689+
line == "-" || line == "*" || line == "+" {
690+
return true
691+
}
692+
// Ordered lists (e.g. "1. ", "12. ")
693+
for i, ch := range line {
694+
if ch >= '0' && ch <= '9' {
695+
continue
696+
}
697+
if ch == '.' && i > 0 && i+1 < len(line) && line[i+1] == ' ' {
698+
return true
699+
}
700+
break
701+
}
702+
// Blockquotes
703+
if strings.HasPrefix(line, ">") {
704+
return true
705+
}
706+
// Horizontal rules (---, ***, ___)
707+
if isHorizontalRule(line) {
708+
return true
709+
}
710+
// Pipe tables
711+
if strings.HasPrefix(line, "|") {
712+
return true
713+
}
714+
return false
715+
}
716+
717+
// isHorizontalRule checks for markdown horizontal rules: three or more
718+
// -, *, or _ characters (with optional spaces).
719+
func isHorizontalRule(line string) bool {
720+
stripped := strings.ReplaceAll(line, " ", "")
721+
if len(stripped) < 3 {
722+
return false
723+
}
724+
ch := stripped[0]
725+
if ch != '-' && ch != '*' && ch != '_' {
726+
return false
727+
}
728+
for _, c := range stripped {
729+
if byte(c) != ch {
730+
return false
731+
}
732+
}
733+
return true
734+
}

0 commit comments

Comments
 (0)