package commitref import ( "fmt" "regexp" "slices" "sort" "strconv" "strings" "unicode" "github.com/bit-issues/backend/internal/tasks" ) // hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word, // non-slash character. The slash exclusion keeps URL fragments like // "https://x/#13" from being parsed as task references; "/" is a \W character, // so a plain (?:^|\W) boundary would consume it. var hashRefPattern = regexp.MustCompile(`(?:^|[^\w/])#(\d+)\b`) // prefixedRefPattern matches "slug#NUMBER" where the slug is preceded by // start-of-string or a single opening boundary character (whitespace, open // punctuation, or the ":" and "," delimiters used in commit subjects such as // "chore:resto-web#13"). Slashes are deliberately not boundaries, so URL paths // ("https://x/foo#1") do not match. RE2 has no lookbehind, so the boundary // character is consumed by a non-capturing group and the slug is captured in // submatch 1. var prefixedRefPattern = regexp.MustCompile(`(?i)(?:^|[\s\[("':,])([a-z0-9][a-z0-9_-]*?)#(\d+)\b`) // Service parses commit messages for task references and maps action keywords // to task status transitions. type Service struct { actions map[string]KeywordAction pattern *regexp.Regexp } // NewService builds a Service from cfg. It validates every keyword status, // rejects keywords that collide after case normalization, and compiles the // keyword matcher from the sorted keyword list. An invalid configuration is // returned as an error so the app fails at startup instead of silently // ignoring commit references. func NewService(cfg Config) (*Service, error) { if len(cfg.ActionKeywords) == 0 { return nil, fmt.Errorf("%w: action_keywords", ErrEmptyKeyword) } actions := make(map[string]KeywordAction, len(cfg.ActionKeywords)) keys := make([]string, 0, len(cfg.ActionKeywords)) for keyword, entry := range cfg.ActionKeywords { if keyword == "" { return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) } kw := strings.ToLower(keyword) if _, exists := actions[kw]; exists { return nil, fmt.Errorf("%w: %q", ErrDuplicateKeyword, kw) } status := tasks.Status(entry.Status) if !status.IsValid() { return nil, fmt.Errorf("%w: status %q for keyword %q", ErrInvalidStatus, entry.Status, keyword) } verb := entry.Verb if verb == "" { verb = titleCase(kw) } actions[kw] = KeywordAction{Status: status, Verb: verb} keys = append(keys, regexp.QuoteMeta(kw)) } sort.Strings(keys) pattern := regexp.MustCompile( `(?i)\b(` + strings.Join(keys, "|") + `)\s+(?:([a-z0-9][a-z0-9_-]*?)#|#)(\d+)\b`, ) return &Service{actions: actions, pattern: pattern}, nil } // ParseCommitMessage scans a commit message for task references. // Returns a list of ParsedReference, one per unique (slug, number) pair found. // A reference may carry an optional project-slug prefix (e.g. "resto-web#13"); // a bare "#NUMBER" keeps ProjectSlug == "" and refers to the current project. // If both a keyword and bare reference exist for the same (slug, number), the // keyword wins. func (s *Service) ParseCommitMessage(message string) []ParsedReference { seen := make(map[refKey]bool) keywordRefs, keywordRanges := s.parseKeywordRefs(message, seen) prefixedRefs, prefixedRanges := parsePrefixedRefs(message, seen, keywordRanges) bareRefs := parseBareRefs(message, seen, keywordRanges, prefixedRanges) var refs []ParsedReference refs = append(refs, keywordRefs...) refs = append(refs, prefixedRefs...) refs = append(refs, bareRefs...) return refs } // parseKeywordRefs scans keyword matches and records each unique reference, // returning the references and the message ranges they occupy. func (s *Service) parseKeywordRefs(message string, seen map[refKey]bool) ([]ParsedReference, []matchRange) { keywordMatches := s.pattern.FindAllStringSubmatchIndex(message, -1) var refs []ParsedReference var ranges []matchRange for _, m := range keywordMatches { keyword := strings.ToLower(message[m[2]:m[3]]) slug := "" if m[4] >= 0 { slug = message[m[4]:m[5]] } number, _ := strconv.Atoi(message[m[6]:m[7]]) key := refKey{slug: strings.ToLower(slug), number: number} if seen[key] { continue } seen[key] = true ranges = append(ranges, matchRange{start: m[0], end: m[1]}) action := s.actions[keyword] refs = append(refs, ParsedReference{ TaskNumber: number, ProjectSlug: slug, Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, CommitHash: "", CommitMessage: message, }) } return refs, ranges } // refKey identifies a reference by its lowercased project slug and task number. // Bare references (no slug) use an empty slug. type refKey struct { slug string number int } type matchRange struct{ start, end int } func (r matchRange) overlaps(other matchRange) bool { return r.start < other.end && other.start < r.end } // overlapsAny reports whether r overlaps any range in ranges. func overlapsAny(r matchRange, ranges []matchRange) bool { return slices.ContainsFunc(ranges, r.overlaps) } // parsePrefixedRefs scans slug#NUMBER references not already seen and not // overlapping a keyword match, returning the references and their ranges. func parsePrefixedRefs( message string, seen map[refKey]bool, keywordRanges []matchRange, ) ([]ParsedReference, []matchRange) { prefixedMatches := prefixedRefPattern.FindAllStringSubmatchIndex(message, -1) var refs []ParsedReference var ranges []matchRange for _, m := range prefixedMatches { slug := message[m[2]:m[3]] number, _ := strconv.Atoi(message[m[4]:m[5]]) key := refKey{slug: strings.ToLower(slug), number: number} if seen[key] { continue } pr := matchRange{start: m[0], end: m[1]} if overlapsAny(pr, keywordRanges) { continue } seen[key] = true ranges = append(ranges, pr) refs = append(refs, ParsedReference{ TaskNumber: number, ProjectSlug: slug, Action: nil, CommitHash: "", CommitMessage: message, }) } return refs, ranges } // parseBareRefs scans #NUMBER references not already seen and not overlapping a // keyword or prefixed match. func parseBareRefs(message string, seen map[refKey]bool, keywordRanges, prefixedRanges []matchRange) []ParsedReference { hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) var refs []ParsedReference for _, m := range hashMatches { number, _ := strconv.Atoi(message[m[2]:m[3]]) key := refKey{slug: "", number: number} if seen[key] { continue } hr := matchRange{start: m[0], end: m[1]} if overlapsAny(hr, keywordRanges) || overlapsAny(hr, prefixedRanges) { continue } seen[key] = true refs = append(refs, ParsedReference{ TaskNumber: number, ProjectSlug: "", Action: nil, CommitHash: "", CommitMessage: message, }) } return refs } // titleCase capitalises the first letter of each space-separated word. func titleCase(s string) string { words := strings.Fields(s) for i, w := range words { runes := []rune(w) if len(runes) > 0 { runes[0] = unicode.ToTitle(runes[0]) words[i] = string(runes) } } return strings.Join(words, " ") }