Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 201 additions & 2 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cmd
import (
"fmt"
"os"
"regexp"
"strings"

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

if len(commits) == 1 {
// Single commit: just use the body
return commits[0].Body, nil
return unwrapParagraphs(commits[0].Body), nil
}

// Multiple commits: format as markdown sections
Expand All @@ -540,10 +545,204 @@ func generatePRBody(g *git.Git, base, head string) (string, error) {
sb.WriteString("\n")
if commit.Body != "" {
sb.WriteString("\n")
sb.WriteString(commit.Body)
sb.WriteString(unwrapParagraphs(commit.Body))
sb.WriteString("\n")
}
}

return sb.String(), nil
}

// htmlTagRe matches anything that looks like an HTML tag, including custom
// elements with hyphens (e.g. <my-component>) and namespaced tags (e.g. <xml:tag>).
var htmlTagRe = regexp.MustCompile(`</?[a-zA-Z][-:a-zA-Z0-9]*[\s/>]`)

// inlineCodeRe matches backtick-enclosed inline code spans so we can strip them
// before checking for HTML. Otherwise `<token>` in code would trigger a false positive.
var inlineCodeRe = regexp.MustCompile("`[^`]+`")

// fenceMarker returns the fence prefix ("```" or "~~~") if the line opens or
// closes a fenced code block, or "" otherwise.
func fenceMarker(trimmedLine string) string {
if strings.HasPrefix(trimmedLine, "```") {
return "```"
}
if strings.HasPrefix(trimmedLine, "~~~") {
return "~~~"
}
return ""
}

// containsHTMLOutsideCode scans the text for HTML tags that appear in prose,
// ignoring content inside fenced code blocks, indented code blocks, and inline
// code spans. Returns true if HTML is found in any prose line.
func containsHTMLOutsideCode(text string) bool {
lines := strings.Split(text, "\n")
var openFence string // tracks the opening fence marker ("```" or "~~~"), empty when outside

for _, line := range lines {
trimmed := strings.TrimRight(line, " \t")
marker := fenceMarker(trimmed)

// Track fenced code blocks — only the matching marker can close a block
if openFence == "" && marker != "" {
openFence = marker
continue
}
if openFence != "" {
if marker == openFence {
openFence = ""
}
continue
}

// Skip indented code blocks (4+ spaces or tab)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}

// Strip inline code spans, then check for HTML
stripped := inlineCodeRe.ReplaceAllString(line, "")
if htmlTagRe.MatchString(stripped) {
return true
}
}

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.
//
// 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
// is doing something intentional with formatting.
func unwrapParagraphs(text string) string {
if text == "" {
return ""
}

// Bail if the text contains HTML tags in prose — don't mess with it.
if containsHTMLOutsideCode(text) {
return text
}

lines := strings.Split(text, "\n")
var result []string
var paragraph []string
var openFence string // tracks the opening fence marker ("```" or "~~~"), empty when outside

flushParagraph := func() {
if len(paragraph) > 0 {
result = append(result, strings.Join(paragraph, " "))
paragraph = nil
}
}

for _, line := range lines {
trimmed := strings.TrimRight(line, " \t")
marker := fenceMarker(trimmed)

// Track fenced code blocks — only the matching marker can close a block
if openFence == "" && marker != "" {
flushParagraph()
result = append(result, line)
openFence = marker
continue
}
if openFence != "" {
result = append(result, line)
if marker == openFence {
openFence = ""
}
continue
}

// Blank line = paragraph break
if trimmed == "" {
flushParagraph()
result = append(result, "")
continue
}

// Preserve lines that are markdown block-level elements
if isBlockElement(trimmed) {
flushParagraph()
result = append(result, line)
continue
}

// Indented code block (4+ spaces or tab)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
flushParagraph()
result = append(result, line)
continue
}

// Otherwise it's a paragraph line — accumulate it
paragraph = append(paragraph, trimmed)
}

flushParagraph()

return strings.Join(result, "\n")
}

// 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 {
// Headers
if strings.HasPrefix(line, "#") {
return true
}
// Unordered lists
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ") ||
line == "-" || line == "*" || line == "+" {
return true
}
// Ordered lists (e.g. "1. ", "12. ")
for i, ch := range line {
if ch >= '0' && ch <= '9' {
continue
}
if ch == '.' && i > 0 && i+1 < len(line) && line[i+1] == ' ' {
return true
}
break
}
// Blockquotes
if strings.HasPrefix(line, ">") {
return true
}
// Horizontal rules (---, ***, ___)
if isHorizontalRule(line) {
return true
}
// Pipe tables
if strings.HasPrefix(line, "|") {
return true
}
return false
}

// isHorizontalRule checks for markdown horizontal rules: three or more
// -, *, or _ characters (with optional spaces).
func isHorizontalRule(line string) bool {
stripped := strings.ReplaceAll(line, " ", "")
if len(stripped) < 3 {
return false
}
ch := stripped[0]
if ch != '-' && ch != '*' && ch != '_' {
return false
}
for _, c := range stripped {
if byte(c) != ch {
return false
}
}
return true
}
Loading