Skip to content

Commit 60366b2

Browse files
authored
fix(submit): unwrap hard-wrapped list items in PR descriptions (#43)
* 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 * fix(submit): clarify comment about non-list block element check Explain why `isBlockElement` is reached only for non-list elements: lists are detected and accumulated earlier via `isListItem`.
1 parent 74fc779 commit 60366b2

File tree

2 files changed

+114
-10
lines changed

2 files changed

+114
-10
lines changed

cmd/submit.go

Lines changed: 56 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,32 @@ 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). Lists
697+
// are handled above via isListItem so they accumulate continuations.
684698
if isBlockElement(trimmed) {
685699
flushParagraph()
686700
result = append(result, line)
687701
continue
688702
}
689703

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

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