Skip to content

Commit a6b3f18

Browse files
committed
fix(submit): unwrap hard-wrapped list items in PR descriptions
List items that were hard-wrapped at ~72 columns in commit messages would produce broken markdown in generated PR descriptions—the continuation line became an orphan paragraph between list items. - Add `isListItem()` to detect list markers (including nested/indented) - Treat list items as accumulation groups (like paragraphs) so continuations are joined back into the item - Check list continuations before the 4-space indent rule so nested list continuations (2 nesting + 2 continuation = 4 spaces) aren't misidentified as indented code blocks
1 parent 74fc779 commit a6b3f18

2 files changed

Lines changed: 113 additions & 10 deletions

File tree

cmd/submit.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -623,11 +623,16 @@ func containsHTMLOutsideCode(text string) bool {
623623
return false
624624
}
625625

626-
// unwrapParagraphs removes hard line breaks within plain-text paragraphs while
627-
// preserving intentional structure: blank lines, markdown block-level syntax
628-
// (headers, lists, blockquotes, horizontal rules), and code blocks (both fenced
629-
// and indented). This converts the ~72-column convention used in commit messages
630-
// into flowing text suitable for GitHub's markdown renderer.
626+
// unwrapParagraphs removes hard line breaks within plain-text paragraphs and
627+
// list items while preserving intentional structure: blank lines, markdown
628+
// block-level syntax (headers, blockquotes, horizontal rules), and code blocks
629+
// (both fenced and indented). This converts the ~72-column convention used in
630+
// commit messages into flowing text suitable for GitHub's markdown renderer.
631+
//
632+
// List items are treated like paragraphs for unwrapping: a hard-wrapped list
633+
// item (with or without continuation indentation) is joined back into a single
634+
// line. Each new list marker starts a fresh accumulation group, so consecutive
635+
// items remain separate.
631636
//
632637
// If HTML tags are found in prose (outside code blocks and inline code spans),
633638
// the entire text is returned as-is — anyone writing raw HTML in a commit message
@@ -680,13 +685,31 @@ func unwrapParagraphs(text string) string {
680685
continue
681686
}
682687

683-
// Preserve lines that are markdown block-level elements
688+
// List items start a new accumulation group so that hard-wrapped
689+
// continuations are joined back into the item, just like paragraphs.
690+
if isListItem(trimmed) {
691+
flushParagraph()
692+
paragraph = append(paragraph, trimmed)
693+
continue
694+
}
695+
696+
// Non-list block elements (headers, blockquotes, rules, tables)
684697
if isBlockElement(trimmed) {
685698
flushParagraph()
686699
result = append(result, line)
687700
continue
688701
}
689702

703+
// Continuation of a list item: strip leading whitespace that may
704+
// come from markdown continuation indentation (e.g. 2-space indent
705+
// under a list marker). This must be checked before the indented
706+
// code block rule — nested list continuations can easily reach 4+
707+
// spaces (2 for nesting + 2 for continuation).
708+
if len(paragraph) > 0 && isListItem(paragraph[0]) {
709+
paragraph = append(paragraph, strings.TrimSpace(trimmed))
710+
continue
711+
}
712+
690713
// Indented code block (4+ spaces or tab)
691714
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
692715
flushParagraph()
@@ -703,6 +726,32 @@ func unwrapParagraphs(text string) string {
703726
return strings.Join(result, "\n")
704727
}
705728

729+
// isListItem returns true if the (possibly indented) line starts a markdown
730+
// list item: unordered ("- ", "* ", "+ ") or ordered ("1. ", "12. ", etc.).
731+
// Indented list items (nested lists) are also detected.
732+
func isListItem(line string) bool {
733+
stripped := strings.TrimLeft(line, " \t")
734+
if stripped == "" {
735+
return false
736+
}
737+
// Unordered lists
738+
if strings.HasPrefix(stripped, "- ") || strings.HasPrefix(stripped, "* ") || strings.HasPrefix(stripped, "+ ") ||
739+
stripped == "-" || stripped == "*" || stripped == "+" {
740+
return true
741+
}
742+
// Ordered lists (e.g. "1. ", "12. ")
743+
for i, ch := range stripped {
744+
if ch >= '0' && ch <= '9' {
745+
continue
746+
}
747+
if ch == '.' && i > 0 && i+1 < len(stripped) && stripped[i+1] == ' ' {
748+
return true
749+
}
750+
break
751+
}
752+
return false
753+
}
754+
706755
// isBlockElement returns true if the line starts with markdown block-level syntax
707756
// that should not be joined with adjacent lines.
708757
func isBlockElement(line string) bool {

cmd/submit_internal_test.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,35 @@ func TestUnwrapParagraphs(t *testing.T) {
5252
want: "Some text.\n\n indented code line 1\n indented code line 2\n\nMore text.",
5353
},
5454
{
55-
name: "unordered list items preserved",
55+
name: "list continuation with indent is joined",
5656
in: "Changes:\n\n- First item\n- Second item that is\n also long\n- Third item",
57-
// The continuation line (2-space indent) is preserved as-is;
58-
// GitHub's markdown renderer already handles this correctly.
59-
want: "Changes:\n\n- First item\n- Second item that is\n also long\n- Third item",
57+
want: "Changes:\n\n- First item\n- Second item that is also long\n- Third item",
58+
},
59+
{
60+
name: "list continuation without indent is joined",
61+
in: "Changes:\n\n- First item\n- Second item that is\nhard-wrapped here\n- Third item",
62+
want: "Changes:\n\n- First item\n- Second item that is hard-wrapped here\n- Third item",
6063
},
6164
{
6265
name: "ordered list items preserved",
6366
in: "Steps:\n\n1. First step\n2. Second step\n3. Third step",
6467
want: "Steps:\n\n1. First step\n2. Second step\n3. Third step",
6568
},
69+
{
70+
name: "hard-wrapped ordered list item is joined",
71+
in: "Steps:\n\n1. First step that is\nhard-wrapped here\n2. Second step",
72+
want: "Steps:\n\n1. First step that is hard-wrapped here\n2. Second step",
73+
},
74+
{
75+
name: "nested list items preserved",
76+
in: "- Item 1\n - Nested item\n - Another nested\n- Item 2",
77+
want: "- Item 1\n - Nested item\n - Another nested\n- Item 2",
78+
},
79+
{
80+
name: "hard-wrapped nested list item is joined",
81+
in: "- Item 1\n - Nested item that is\n also long\n- Item 2",
82+
want: "- Item 1\n - Nested item that is also long\n- Item 2",
83+
},
6684
{
6785
name: "headers preserved",
6886
in: "## Section\n\nParagraph that is\nhard-wrapped here.\n\n### Subsection\n\nAnother para.",
@@ -171,6 +189,42 @@ func TestContainsHTMLOutsideCode(t *testing.T) {
171189
}
172190
}
173191

192+
func TestIsListItem(t *testing.T) {
193+
listLines := []string{
194+
"- item",
195+
"* item",
196+
"+ item",
197+
"-",
198+
"*",
199+
"+",
200+
"1. ordered",
201+
"12. multi-digit",
202+
" - indented unordered",
203+
" * indented star",
204+
" 1. indented ordered",
205+
"\t- tab indented",
206+
}
207+
for _, line := range listLines {
208+
if !isListItem(line) {
209+
t.Errorf("expected isListItem(%q) = true", line)
210+
}
211+
}
212+
213+
nonListLines := []string{
214+
"just text",
215+
"# Header",
216+
"> blockquote",
217+
"| table",
218+
"2nd place finish",
219+
"",
220+
}
221+
for _, line := range nonListLines {
222+
if isListItem(line) {
223+
t.Errorf("expected isListItem(%q) = false", line)
224+
}
225+
}
226+
}
227+
174228
func TestIsBlockElement(t *testing.T) {
175229
blockLines := []string{
176230
"# Header",

0 commit comments

Comments
 (0)