From a6b3f18e9e24a289db7e17448631d6cc1fb709d7 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 13 Feb 2026 17:32:35 -0800 Subject: [PATCH 1/2] fix(submit): unwrap hard-wrapped list items in PR descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/submit.go | 61 ++++++++++++++++++++++++++++++++---- cmd/submit_internal_test.go | 62 ++++++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index 126a357..5a86802 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -623,11 +623,16 @@ func containsHTMLOutsideCode(text string) bool { return false } -// unwrapParagraphs removes hard line breaks within plain-text paragraphs while -// preserving intentional structure: blank lines, markdown block-level syntax -// (headers, lists, blockquotes, horizontal rules), and code blocks (both fenced -// and indented). This converts the ~72-column convention used in commit messages -// into flowing text suitable for GitHub's markdown renderer. +// unwrapParagraphs removes hard line breaks within plain-text paragraphs and +// list items while preserving intentional structure: blank lines, markdown +// block-level syntax (headers, blockquotes, horizontal rules), and code blocks +// (both fenced and indented). This converts the ~72-column convention used in +// commit messages into flowing text suitable for GitHub's markdown renderer. +// +// List items are treated like paragraphs for unwrapping: a hard-wrapped list +// item (with or without continuation indentation) is joined back into a single +// line. Each new list marker starts a fresh accumulation group, so consecutive +// items remain separate. // // If HTML tags are found in prose (outside code blocks and inline code spans), // the entire text is returned as-is — anyone writing raw HTML in a commit message @@ -680,13 +685,31 @@ func unwrapParagraphs(text string) string { continue } - // Preserve lines that are markdown block-level elements + // List items start a new accumulation group so that hard-wrapped + // continuations are joined back into the item, just like paragraphs. + if isListItem(trimmed) { + flushParagraph() + paragraph = append(paragraph, trimmed) + continue + } + + // Non-list block elements (headers, blockquotes, rules, tables) if isBlockElement(trimmed) { flushParagraph() result = append(result, line) continue } + // Continuation of a list item: strip leading whitespace that may + // come from markdown continuation indentation (e.g. 2-space indent + // under a list marker). This must be checked before the indented + // code block rule — nested list continuations can easily reach 4+ + // spaces (2 for nesting + 2 for continuation). + if len(paragraph) > 0 && isListItem(paragraph[0]) { + paragraph = append(paragraph, strings.TrimSpace(trimmed)) + continue + } + // Indented code block (4+ spaces or tab) if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { flushParagraph() @@ -703,6 +726,32 @@ func unwrapParagraphs(text string) string { return strings.Join(result, "\n") } +// isListItem returns true if the (possibly indented) line starts a markdown +// list item: unordered ("- ", "* ", "+ ") or ordered ("1. ", "12. ", etc.). +// Indented list items (nested lists) are also detected. +func isListItem(line string) bool { + stripped := strings.TrimLeft(line, " \t") + if stripped == "" { + return false + } + // Unordered lists + if strings.HasPrefix(stripped, "- ") || strings.HasPrefix(stripped, "* ") || strings.HasPrefix(stripped, "+ ") || + stripped == "-" || stripped == "*" || stripped == "+" { + return true + } + // Ordered lists (e.g. "1. ", "12. ") + for i, ch := range stripped { + if ch >= '0' && ch <= '9' { + continue + } + if ch == '.' && i > 0 && i+1 < len(stripped) && stripped[i+1] == ' ' { + return true + } + break + } + return false +} + // isBlockElement returns true if the line starts with markdown block-level syntax // that should not be joined with adjacent lines. func isBlockElement(line string) bool { diff --git a/cmd/submit_internal_test.go b/cmd/submit_internal_test.go index 5cd21d8..38abac5 100644 --- a/cmd/submit_internal_test.go +++ b/cmd/submit_internal_test.go @@ -52,17 +52,35 @@ func TestUnwrapParagraphs(t *testing.T) { want: "Some text.\n\n indented code line 1\n indented code line 2\n\nMore text.", }, { - name: "unordered list items preserved", + name: "list continuation with indent is joined", in: "Changes:\n\n- First item\n- Second item that is\n also long\n- Third item", - // The continuation line (2-space indent) is preserved as-is; - // GitHub's markdown renderer already handles this correctly. - want: "Changes:\n\n- First item\n- Second item that is\n also long\n- Third item", + want: "Changes:\n\n- First item\n- Second item that is also long\n- Third item", + }, + { + name: "list continuation without indent is joined", + in: "Changes:\n\n- First item\n- Second item that is\nhard-wrapped here\n- Third item", + want: "Changes:\n\n- First item\n- Second item that is hard-wrapped here\n- Third item", }, { name: "ordered list items preserved", in: "Steps:\n\n1. First step\n2. Second step\n3. Third step", want: "Steps:\n\n1. First step\n2. Second step\n3. Third step", }, + { + name: "hard-wrapped ordered list item is joined", + in: "Steps:\n\n1. First step that is\nhard-wrapped here\n2. Second step", + want: "Steps:\n\n1. First step that is hard-wrapped here\n2. Second step", + }, + { + name: "nested list items preserved", + in: "- Item 1\n - Nested item\n - Another nested\n- Item 2", + want: "- Item 1\n - Nested item\n - Another nested\n- Item 2", + }, + { + name: "hard-wrapped nested list item is joined", + in: "- Item 1\n - Nested item that is\n also long\n- Item 2", + want: "- Item 1\n - Nested item that is also long\n- Item 2", + }, { name: "headers preserved", in: "## Section\n\nParagraph that is\nhard-wrapped here.\n\n### Subsection\n\nAnother para.", @@ -171,6 +189,42 @@ func TestContainsHTMLOutsideCode(t *testing.T) { } } +func TestIsListItem(t *testing.T) { + listLines := []string{ + "- item", + "* item", + "+ item", + "-", + "*", + "+", + "1. ordered", + "12. multi-digit", + " - indented unordered", + " * indented star", + " 1. indented ordered", + "\t- tab indented", + } + for _, line := range listLines { + if !isListItem(line) { + t.Errorf("expected isListItem(%q) = true", line) + } + } + + nonListLines := []string{ + "just text", + "# Header", + "> blockquote", + "| table", + "2nd place finish", + "", + } + for _, line := range nonListLines { + if isListItem(line) { + t.Errorf("expected isListItem(%q) = false", line) + } + } +} + func TestIsBlockElement(t *testing.T) { blockLines := []string{ "# Header", From e90f53972c88c600c9f79d8606abe71c28599145 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Fri, 13 Feb 2026 18:20:02 -0800 Subject: [PATCH 2/2] 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`. --- cmd/submit.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/submit.go b/cmd/submit.go index 5a86802..ee9c9e8 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -693,7 +693,8 @@ func unwrapParagraphs(text string) string { continue } - // Non-list block elements (headers, blockquotes, rules, tables) + // Non-list block elements (headers, blockquotes, rules, tables). Lists + // are handled above via isListItem so they accumulate continuations. if isBlockElement(trimmed) { flushParagraph() result = append(result, line)