| | | 1 | | package git |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "regexp" |
| | | 5 | | "strings" |
| | | 6 | | |
| | | 7 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 8 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 9 | | ) |
| | | 10 | | |
| | | 11 | | // Compile-time interface compliance check. |
| | | 12 | | var _ ports.CommitParser = (*ConventionalCommitParser)(nil) |
| | | 13 | | |
| | | 14 | | var conventionalCommitRe = regexp.MustCompile( |
| | | 15 | | `^(?P<type>\w+)` + |
| | | 16 | | `(?:\((?P<scope>[^)]*)\))?` + |
| | | 17 | | `(?P<breaking>!)?` + |
| | | 18 | | `:\s*(?P<description>.+)$`, |
| | | 19 | | ) |
| | | 20 | | |
| | | 21 | | // ConventionalCommitParser implements ports.CommitParser for Conventional Commits. |
| | | 22 | | type ConventionalCommitParser struct{} |
| | | 23 | | |
| | | 24 | | // NewConventionalCommitParser creates a new parser. |
| | | 25 | | func NewConventionalCommitParser() *ConventionalCommitParser { |
| | | 26 | | return &ConventionalCommitParser{} |
| | | 27 | | } |
| | | 28 | | |
| | 11 | 29 | | func (p *ConventionalCommitParser) Parse(message string) (domain.Commit, error) { |
| | 11 | 30 | | lines := strings.SplitN(message, "\n", 2) |
| | 11 | 31 | | subject := strings.TrimSpace(lines[0]) |
| | 11 | 32 | | |
| | 11 | 33 | | matches := conventionalCommitRe.FindStringSubmatch(subject) |
| | 1 | 34 | | if matches == nil { |
| | 1 | 35 | | return domain.Commit{ |
| | 1 | 36 | | Message: subject, |
| | 1 | 37 | | Description: subject, |
| | 1 | 38 | | }, nil |
| | 1 | 39 | | } |
| | | 40 | | |
| | 10 | 41 | | commit := domain.Commit{ |
| | 10 | 42 | | Message: subject, |
| | 10 | 43 | | Type: matches[1], |
| | 10 | 44 | | Scope: matches[2], |
| | 10 | 45 | | Description: matches[4], |
| | 10 | 46 | | } |
| | 10 | 47 | | |
| | 10 | 48 | | // Check for ! marker. |
| | 2 | 49 | | if matches[3] == "!" { |
| | 2 | 50 | | commit.IsBreakingChange = true |
| | 2 | 51 | | } |
| | | 52 | | |
| | | 53 | | // Parse body and footer. |
| | 4 | 54 | | if len(lines) > 1 { |
| | 4 | 55 | | body := strings.TrimSpace(lines[1]) |
| | 4 | 56 | | commit.Body, commit.Footer = splitBodyFooter(body) |
| | 4 | 57 | | detectBreakingChange(&commit) |
| | 4 | 58 | | } |
| | | 59 | | |
| | 10 | 60 | | return commit, nil |
| | | 61 | | } |
| | | 62 | | |
| | | 63 | | func splitBodyFooter(text string) (body, footer string) { |
| | | 64 | | // Footer is separated from body by a blank line and starts with a token. |
| | | 65 | | parts := strings.Split(text, "\n\n") |
| | | 66 | | if len(parts) <= 1 { |
| | | 67 | | return text, "" |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | lastPart := parts[len(parts)-1] |
| | | 71 | | if isFooter(lastPart) { |
| | | 72 | | return strings.Join(parts[:len(parts)-1], "\n\n"), lastPart |
| | | 73 | | } |
| | | 74 | | return text, "" |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | var footerTokenRe = regexp.MustCompile(`^[\w-]+(?:: | #)`) |
| | | 78 | | |
| | | 79 | | func isFooter(text string) bool { |
| | | 80 | | lines := strings.Split(text, "\n") |
| | | 81 | | if len(lines) == 0 { |
| | | 82 | | return false |
| | | 83 | | } |
| | | 84 | | return footerTokenRe.MatchString(lines[0]) || |
| | | 85 | | strings.HasPrefix(lines[0], "BREAKING CHANGE:") || |
| | | 86 | | strings.HasPrefix(lines[0], "BREAKING-CHANGE:") |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | func detectBreakingChange(commit *domain.Commit) { |
| | | 90 | | for _, prefix := range []string{"BREAKING CHANGE:", "BREAKING-CHANGE:"} { |
| | | 91 | | if note := findBreakingNote(commit.Footer, prefix); note != "" { |
| | | 92 | | commit.IsBreakingChange = true |
| | | 93 | | commit.BreakingNote = note |
| | | 94 | | return |
| | | 95 | | } |
| | | 96 | | if note := findBreakingNote(commit.Body, prefix); note != "" { |
| | | 97 | | commit.IsBreakingChange = true |
| | | 98 | | commit.BreakingNote = note |
| | | 99 | | return |
| | | 100 | | } |
| | | 101 | | } |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | func findBreakingNote(text, prefix string) string { |
| | | 105 | | for _, line := range strings.Split(text, "\n") { |
| | | 106 | | line = strings.TrimSpace(line) |
| | | 107 | | if strings.HasPrefix(line, prefix) { |
| | | 108 | | return strings.TrimSpace(strings.TrimPrefix(line, prefix)) |
| | | 109 | | } |
| | | 110 | | } |
| | | 111 | | return "" |
| | | 112 | | } |