(** Parsing commit messages and Conventional Commits metadata. Extracted from the view layer so that both handlers (for filtering) and views (for display) can use it without a layer violation. *) type t = { summary : string; body : string } let parse = function | None -> { summary = ""; body = "" } | Some message -> ( match String.split_on_char '\n' message with | [] -> { summary = ""; body = "" } | summary :: rest -> { summary; body = String.concat "\n" rest |> String.trim }) let conventional_commit_types = [ "feat"; "fix"; "docs"; "style"; "refactor"; "perf"; "test"; "build"; "ci"; "chore"; "revert"; "remove"; ] (** Split a Conventional Commits subject into its type and the remaining title. An unrecognised prefix is left in the title untouched, so non-conforming histories still read correctly. *) let parse_conventional summary = match String.index_opt summary ':' with | None -> (None, summary) | Some colon_pos -> let prefix = String.sub summary 0 colon_pos in let type_name = match String.index_opt prefix '(' with | Some paren_pos -> String.sub prefix 0 paren_pos | None -> prefix in let type_lower = String.lowercase_ascii type_name in if List.mem type_lower conventional_commit_types then let rest = String.sub summary (colon_pos + 1) (String.length summary - colon_pos - 1) |> String.trim in (Some type_lower, rest) else (None, summary)