(** Changelog generation for monopam. This module handles generating weekly and daily changelog entries using Claude AI to analyze git commit history and produce user-facing change summaries. Changes are stored in a .changes directory at the monorepo root: - .changes/.json - weekly changelog entries - .changes/-.json - daily changelog entries (one file per day per repo) - .changes/YYYYMMDD.json - aggregated daily changes for broadcasting {1 Submodules} - {!Aggregated} - Types and I/O for aggregated daily changes (YYYYMMDD.json) - {!Daily} - Types and I/O for per-day-per-repo changes (repo-YYYY-MM-DD.json) - {!Query} - High-level query interface for changes *) module Aggregated = Changes_aggregated (** Re-export submodules for querying changes *) module Daily = Changes_daily module Query = Changes_query type commit_range = { from_hash : string; to_hash : string; count : int } type weekly_entry = { week_start : string; (* ISO date YYYY-MM-DD, Monday *) week_end : string; (* ISO date YYYY-MM-DD, Sunday *) summary : string; (* One-line summary *) changes : string list; (* Bullet points *) commit_range : commit_range; } type daily_entry = { date : string; (* ISO date YYYY-MM-DD *) hour : int; (* Hour of day 0-23 *) timestamp : Ptime.t; (* RFC3339 timestamp for precise ordering *) summary : string; (* One-line summary *) changes : string list; (* Bullet points *) commit_range : commit_range; contributors : string list; (* List of contributors for this entry *) repo_url : string option; (* Upstream repository URL *) } type changes_file = { repository : string; entries : weekly_entry list } type daily_changes_file = { repository : string; entries : daily_entry list } (** Mode for changelog generation *) type mode = Weekly | Daily (* Jsont codecs *) let commit_range_jsont = let make from_hash to_hash count = { from_hash; to_hash; count } in Jsont.Object.map ~kind:"commit_range" make |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash) |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash) |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count) |> Jsont.Object.finish let weekly_entry_jsont : weekly_entry Jsont.t = let make week_start week_end summary changes commit_range : weekly_entry = { week_start; week_end; summary; changes; commit_range } in Jsont.Object.map ~kind:"weekly_entry" make |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_start) |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_end) |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : weekly_entry) -> e.summary) |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : weekly_entry) -> e.changes) |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : weekly_entry) -> e.commit_range) |> Jsont.Object.finish let changes_file_jsont : changes_file Jsont.t = let make repository entries : changes_file = { repository; entries } in Jsont.Object.map ~kind:"changes_file" make |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : changes_file) -> f.repository) |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont) ~enc:(fun (f : changes_file) -> f.entries) |> Jsont.Object.finish let ptime_jsont = let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in let dec s = match Ptime.of_rfc3339 s with | Ok (t, _, _) -> t | Error _ -> failwith ("Invalid timestamp: " ^ s) in Jsont.map ~dec ~enc Jsont.string let daily_entry_jsont : daily_entry Jsont.t = let make date hour timestamp summary changes commit_range contributors repo_url : daily_entry = { date; hour; timestamp; summary; changes; commit_range; contributors; repo_url; } in (* Default hour and timestamp for backwards compat when reading old files *) let default_hour = 0 in let default_timestamp = Ptime.epoch in Jsont.Object.map ~kind:"daily_entry" make |> Jsont.Object.mem "date" Jsont.string ~enc:(fun (e : daily_entry) -> e.date) |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun (e : daily_entry) -> e.hour) |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun (e : daily_entry) -> e.timestamp) |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) -> e.summary) |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : daily_entry) -> e.changes) |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : daily_entry) -> e.commit_range) |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun (e : daily_entry) -> e.contributors) |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun (e : daily_entry) -> e.repo_url) |> Jsont.Object.finish let daily_changes_file_jsont : daily_changes_file Jsont.t = let make repository entries : daily_changes_file = { repository; entries } in Jsont.Object.map ~kind:"daily_changes_file" make |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : daily_changes_file) -> f.repository) |> Jsont.Object.mem "entries" (Jsont.list daily_entry_jsont) ~enc:(fun (f : daily_changes_file) -> f.entries) |> Jsont.Object.finish (* File I/O *) (* Helper to ensure .changes directory exists *) let ensure_changes_dir ~fs monorepo = let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in match Eio.Path.kind ~follow:true changes_dir with | `Directory -> () | _ -> Eio.Path.mkdir ~perm:0o755 changes_dir | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 changes_dir (* Load weekly changes from .changes/.json in monorepo *) let load ~fs ~monorepo repo_name = let file_path = Eio.Path.( fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ ".json")) in match Eio.Path.kind ~follow:true file_path with | `Regular_file -> ( let content = Eio.Path.load file_path in match Jsont_bytesrw.decode_string changes_file_jsont content with | Ok cf -> Ok cf | Error e -> Error (Format.sprintf "Failed to parse %s.json: %s" repo_name e)) | _ -> Ok { repository = repo_name; entries = [] } | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] } (* Save weekly changes to .changes/.json in monorepo *) let save ~fs ~monorepo (cf : changes_file) = ensure_changes_dir ~fs monorepo; let file_path = Eio.Path.( fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ ".json")) in match Jsont_bytesrw.encode_string ~format:Jsont.Indent changes_file_jsont cf with | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o644) file_path content; Ok () | Error e -> Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e) (* Filename for daily changes: -.json *) let daily_filename repo_name date = repo_name ^ "-" ^ date ^ ".json" (* Check if daily file exists on disk *) let daily_exists ~fs ~monorepo ~date repo_name = let filename = daily_filename repo_name date in let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in match Eio.Path.kind ~follow:true file_path with | `Regular_file -> true | _ -> false | exception Eio.Io _ -> false (* Load daily changes from .changes/-.json in monorepo *) let load_daily ~fs ~monorepo ~date repo_name = let filename = daily_filename repo_name date in let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in match Eio.Path.kind ~follow:true file_path with | `Regular_file -> ( let content = Eio.Path.load file_path in match Jsont_bytesrw.decode_string daily_changes_file_jsont content with | Ok cf -> Ok cf | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e)) | _ -> Ok { repository = repo_name; entries = [] } | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] } (* Save daily changes to .changes/-.json in monorepo *) let save_daily ~fs ~monorepo ~date (cf : daily_changes_file) = ensure_changes_dir ~fs monorepo; let filename = daily_filename cf.repository date in let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in match Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf with | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o644) file_path content; Ok () | Error e -> Error (Format.sprintf "Failed to encode %s: %s" filename e) (* Markdown generation *) let to_markdown (cf : changes_file) = let buf = Buffer.create 1024 in Buffer.add_string buf (Printf.sprintf "# %s Changelog\n\n" cf.repository); List.iter (fun (entry : weekly_entry) -> Buffer.add_string buf (Printf.sprintf "## Week of %s to %s\n\n" entry.week_start entry.week_end); Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary); List.iter (fun change -> Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes; Buffer.add_string buf "\n") cf.entries; Buffer.contents buf let aggregate ~history (cfs : changes_file list) = (* Collect all entries from all files, tagged with repository *) let all_entries = List.concat_map (fun (cf : changes_file) -> List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries) cfs in (* Sort by week_start descending *) let sorted = List.sort (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) -> String.compare e2.week_start e1.week_start) all_entries in (* Group by week *) let rec group_by_week acc current_week current_group = function | [] -> if current_group <> [] then (current_week, List.rev current_group) :: acc else acc | (repo, (entry : weekly_entry)) :: rest -> let week_key = entry.week_start ^ " to " ^ entry.week_end in if current_week = "" || current_week = week_key then group_by_week acc week_key ((repo, entry) :: current_group) rest else group_by_week ((current_week, List.rev current_group) :: acc) week_key [ (repo, entry) ] rest in let grouped = List.rev (group_by_week [] "" [] sorted) in (* Take only the requested number of weeks *) let limited = if history > 0 then List.filteri (fun i _ -> i < history) grouped else grouped in (* Generate markdown *) let buf = Buffer.create 4096 in Buffer.add_string buf "# Changelog\n\n"; List.iter (fun (week_key, entries) -> Buffer.add_string buf (Printf.sprintf "## Week of %s\n\n" week_key); List.iter (fun (repo, (entry : weekly_entry)) -> Buffer.add_string buf (Printf.sprintf "### %s\n" repo); Buffer.add_string buf (Printf.sprintf "%s\n" entry.summary); List.iter (fun change -> Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes; Buffer.add_string buf "\n") entries) limited; Buffer.contents buf (* Week calculation *) (* Get day of week: 0 = Sunday, 1 = Monday, ... 6 = Saturday Using Zeller's congruence for Gregorian calendar *) let day_of_week year month day = let y = if month < 3 then year - 1 else year in let m = if month < 3 then month + 12 else month in let q = day in let k = y mod 100 in let j = y / 100 in let h = (q + (13 * (m + 1) / 5) + k + (k / 4) + (j / 4) - (2 * j)) mod 7 in (* Convert from Zeller's (0=Sat) to standard (0=Sun) *) (h + 6) mod 7 let add_days (y, m, d) n = (* Simple day addition - handles month/year boundaries *) let days_in_month year month = match month with | 1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31 | 4 | 6 | 9 | 11 -> 30 | 2 -> if (year mod 4 = 0 && year mod 100 <> 0) || year mod 400 = 0 then 29 else 28 | _ -> 30 in let rec loop y m d n = if n = 0 then (y, m, d) else if n > 0 then let dim = days_in_month y m in if d + n <= dim then (y, m, d + n) else let remaining = dim - d in let new_m = if m = 12 then 1 else m + 1 in let new_y = if m = 12 then y + 1 else y in loop new_y new_m 1 (n - remaining - 1) else if (* n < 0 *) d + n >= 1 then (y, m, d + n) else let new_m = if m = 1 then 12 else m - 1 in let new_y = if m = 1 then y - 1 else y in let dim = days_in_month new_y new_m in loop new_y new_m dim (n + d) in loop y m d n let format_date (y, m, d) = Printf.sprintf "%04d-%02d-%02d" y m d let week_of_date (y, m, d) = let dow = day_of_week y m d in (* Monday = 1, so days to subtract to get to Monday *) let days_to_monday = if dow = 0 then 6 else dow - 1 in let monday = add_days (y, m, d) (-days_to_monday) in let sunday = add_days monday 6 in (format_date monday, format_date sunday) let week_of_ptime t = let (y, m, d), _ = Ptime.to_date_time t in week_of_date (y, m, d) let has_week (cf : changes_file) ~week_start = List.exists (fun (e : weekly_entry) -> e.week_start = week_start) cf.entries let date_of_ptime t = let (y, m, d), _ = Ptime.to_date_time t in format_date (y, m, d) let has_day (cf : daily_changes_file) ~date:_ = (* With per-day files, the file is already for a specific date. This function now checks if the file has any entries. *) cf.entries <> [] (* Aggregate daily changes into DAILY-CHANGES.md *) let aggregate_daily ~history (cfs : daily_changes_file list) = (* Collect all entries from all files, tagged with repository *) let all_entries = List.concat_map (fun (cf : daily_changes_file) -> List.map (fun (e : daily_entry) -> (cf.repository, e)) cf.entries) cfs in (* Sort by date descending *) let sorted = List.sort (fun (_, (e1 : daily_entry)) (_, (e2 : daily_entry)) -> String.compare e2.date e1.date) all_entries in (* Group by date *) let rec group_by_date acc current_date current_group = function | [] -> if current_group <> [] then (current_date, List.rev current_group) :: acc else acc | (repo, (entry : daily_entry)) :: rest -> if current_date = "" || current_date = entry.date then group_by_date acc entry.date ((repo, entry) :: current_group) rest else group_by_date ((current_date, List.rev current_group) :: acc) entry.date [ (repo, entry) ] rest in let grouped = List.rev (group_by_date [] "" [] sorted) in (* Take only the requested number of days *) let limited = if history > 0 then List.filteri (fun i _ -> i < history) grouped else grouped in (* Generate markdown - only include repos with actual changes *) let buf = Buffer.create 4096 in Buffer.add_string buf "# Daily Changelog\n\n"; List.iter (fun (date, entries) -> (* Filter out entries with empty changes - these are repos with no changes *) let entries_with_changes = List.filter (fun (_, (entry : daily_entry)) -> entry.changes <> []) entries in if entries_with_changes <> [] then begin Buffer.add_string buf (Printf.sprintf "## %s\n\n" date); List.iter (fun (repo, (entry : daily_entry)) -> (* Format repo name with link if URL available *) let repo_header = match entry.repo_url with | Some url -> Printf.sprintf "[%s](%s)" repo url | None -> repo in Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_header); Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary); List.iter (fun change -> Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes; (* Add contributors if any *) if entry.contributors <> [] then begin let contributors_str = String.concat ", " entry.contributors in Buffer.add_string buf (Printf.sprintf "\n*Contributors: %s*\n" contributors_str) end; Buffer.add_string buf "\n") entries_with_changes end) limited; Buffer.contents buf (* Claude prompt generation *) let generate_weekly_prompt ~repository ~week_start ~week_end commits = let buf = Buffer.create 4096 in Buffer.add_string buf (Printf.sprintf "You are analyzing git commits for the OCaml library \"%s\".\n" repository); Buffer.add_string buf (Printf.sprintf "Generate a user-facing changelog entry for the week of %s to %s.\n\n" week_start week_end); Buffer.add_string buf "## Commits this week:\n\n"; List.iter (fun (commit : Git.log_entry) -> Buffer.add_string buf (Printf.sprintf "### %s by %s (%s)\n" (String.sub commit.hash 0 (min 7 (String.length commit.hash))) commit.author commit.date); Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject); if commit.body <> "" then begin Buffer.add_string buf (Printf.sprintf "%s\n" commit.body) end; Buffer.add_string buf "---\n\n") commits; Buffer.add_string buf {|## Instructions: 1. Focus on USER-FACING changes only. Skip: - Internal refactoring with no API impact - CI/build system tweaks - Typo fixes in code comments - Dependency bumps (unless they add features) 2. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty summary and empty changes array. Do NOT write "no changes" or similar text. Example for no changes: {"summary": "", "changes": []} 3. Otherwise, respond in this exact JSON format: { "summary": "One sentence describing the most important change", "changes": [ "First user-facing change as a bullet point", "Second change", "..." ] } 4. Write for developers using this library. Be: - Concise (max 80 chars per bullet) - Specific (mention function/module names) - Action-oriented (start with verbs: Added, Fixed, Improved, Removed) 5. Maximum 5 bullet points. Group related changes if needed. |}; Buffer.contents buf let generate_daily_prompt ~repository ~date commits = let buf = Buffer.create 4096 in Buffer.add_string buf (Printf.sprintf "You are analyzing git commits for the OCaml library \"%s\".\n" repository); Buffer.add_string buf (Printf.sprintf "Generate a user-facing changelog entry for %s.\n\n" date); Buffer.add_string buf "## Commits today:\n\n"; List.iter (fun (commit : Git.log_entry) -> Buffer.add_string buf (Printf.sprintf "### %s by %s (%s)\n" (String.sub commit.hash 0 (min 7 (String.length commit.hash))) commit.author commit.date); Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject); if commit.body <> "" then begin Buffer.add_string buf (Printf.sprintf "%s\n" commit.body) end; Buffer.add_string buf "---\n\n") commits; Buffer.add_string buf {|## Instructions: 1. Focus on USER-FACING changes only. Skip: - Internal refactoring with no API impact - CI/build system tweaks - Typo fixes in code comments - Dependency bumps (unless they add features) 2. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty summary and empty changes array. Do NOT write "no changes" or similar text. Example for no changes: {"summary": "", "changes": []} 3. Otherwise, respond in this exact JSON format: { "summary": "One sentence describing the most important change", "changes": [ "First user-facing change as a bullet point", "Second change", "..." ] } 4. Write for developers using this library. Be: - Concise (max 80 chars per bullet) - Specific (mention function/module names) - Action-oriented (start with verbs: Added, Fixed, Improved, Removed) 5. Maximum 5 bullet points. Group related changes if needed. |}; Buffer.contents buf (* Backwards compatibility *) let generate_prompt = generate_weekly_prompt (* Response parsing *) type claude_response = { summary : string; changes : string list } let claude_response_jsont = let make summary changes = { summary; changes } in Jsont.Object.map ~kind:"claude_response" make |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun r -> r.summary) |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun r -> r.changes) |> Jsont.Object.finish let parse_claude_response text = let text = String.trim text in (* Legacy support for NO_CHANGES response *) if text = "NO_CHANGES" then Ok None else match Jsont_bytesrw.decode_string claude_response_jsont text with | Ok r -> (* Treat empty summary and changes as no changes *) if r.summary = "" && r.changes = [] then Ok None else Ok (Some r) | Error e -> Error (Format.sprintf "Failed to parse Claude response: %s" e) (* Main analysis function *) let analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end commits = if commits = [] then Ok None else begin let prompt = generate_prompt ~repository ~week_start ~week_end commits in (* Create Claude options with structured output *) let output_schema = let open Jsont in Object ( [ (("type", Meta.none), String ("object", Meta.none)); ( ("properties", Meta.none), Object ( [ ( ("summary", Meta.none), Object ( [ (("type", Meta.none), String ("string", Meta.none)) ], Meta.none ) ); ( ("changes", Meta.none), Object ( [ (("type", Meta.none), String ("array", Meta.none)); ( ("items", Meta.none), Object ( [ ( ("type", Meta.none), String ("string", Meta.none) ); ], Meta.none ) ); ], Meta.none ) ); ], Meta.none ) ); ( ("required", Meta.none), Array ( [ String ("summary", Meta.none); String ("changes", Meta.none); ], Meta.none ) ); ], Meta.none ) in let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in let options = Claude.Options.default |> Claude.Options.with_output_format output_format |> Claude.Options.with_max_turns 1 in let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in Claude.Client.query client prompt; let responses = Claude.Client.receive_all client in let result = ref None in List.iter (function | Claude.Response.Complete c -> ( match Claude.Response.Complete.structured_output c with | Some json -> ( match Jsont.Json.decode claude_response_jsont json with | Ok r -> result := Some (Ok (Some r)) | Error e -> result := Some (Error (Format.sprintf "Failed to decode response: %s" e))) | None -> ( (* Try to get text and parse it as fallback *) match Claude.Response.Complete.result_text c with | Some text -> result := Some (parse_claude_response text) | None -> result := Some (Ok None))) | Claude.Response.Text t -> let text = Claude.Response.Text.content t in if String.trim text = "NO_CHANGES" then result := Some (Ok None) | Claude.Response.Error e -> result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e))) | _ -> ()) responses; match !result with Some r -> r | None -> Ok None end (* Daily analysis function *) let analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits = if commits = [] then Ok None else begin let prompt = generate_daily_prompt ~repository ~date commits in (* Create Claude options with structured output *) let output_schema = let open Jsont in Object ( [ (("type", Meta.none), String ("object", Meta.none)); ( ("properties", Meta.none), Object ( [ ( ("summary", Meta.none), Object ( [ (("type", Meta.none), String ("string", Meta.none)) ], Meta.none ) ); ( ("changes", Meta.none), Object ( [ (("type", Meta.none), String ("array", Meta.none)); ( ("items", Meta.none), Object ( [ ( ("type", Meta.none), String ("string", Meta.none) ); ], Meta.none ) ); ], Meta.none ) ); ], Meta.none ) ); ( ("required", Meta.none), Array ( [ String ("summary", Meta.none); String ("changes", Meta.none); ], Meta.none ) ); ], Meta.none ) in let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in let options = Claude.Options.default |> Claude.Options.with_output_format output_format |> Claude.Options.with_max_turns 1 in let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in Claude.Client.query client prompt; let responses = Claude.Client.receive_all client in let result = ref None in List.iter (function | Claude.Response.Complete c -> ( match Claude.Response.Complete.structured_output c with | Some json -> ( match Jsont.Json.decode claude_response_jsont json with | Ok r -> (* Treat empty response as no changes *) if r.summary = "" && r.changes = [] then result := Some (Ok None) else result := Some (Ok (Some r)) | Error e -> result := Some (Error (Format.sprintf "Failed to decode response: %s" e))) | None -> ( (* Try to get text and parse it as fallback *) match Claude.Response.Complete.result_text c with | Some text -> result := Some (parse_claude_response text) | None -> result := Some (Ok None))) | Claude.Response.Text t -> let text = Claude.Response.Text.content t in if String.trim text = "NO_CHANGES" then result := Some (Ok None) | Claude.Response.Error e -> result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e))) | _ -> ()) responses; match !result with Some r -> r | None -> Ok None end (* Refine daily changelog markdown to be more narrative *) let refine_daily_changelog ~sw ~process_mgr ~clock markdown = let prompt = Printf.sprintf {|You are editing a daily changelog for an OCaml monorepo. Your task is to refine the following changelog to be: 1. More narrative and human-readable - write it as a daily update that developers will want to read 2. Grouped by related changes - if multiple repos have related changes, group them together 3. Succinct but complete - don't lose any information, but make it more concise 4. Well-ordered - put the most significant changes first Keep the markdown format with: - A main heading for each date - Sub-sections for related groups of changes (not necessarily by repo), such as "New Libraries", "Major Features", "Critical Bug Fixes", "Code Quality Improvements", "Documentation Updates" - Bullet points for individual changes - Preserve all contributor attributions (format: — *Contributor Name*) - IMPORTANT: Every repository name MUST be a markdown link. If a repo already has a link, preserve it. If not, generate one using the pattern: [repo-name](https://tangled.org/@anil.recoil.org/repo-name.git) - Format each bullet as: **[repo-name](url)**: Description — *Contributors* (if any) IMPORTANT: For "initial import" or "added as subtree" entries: - Put these in a dedicated "New Libraries" section - Expand the description to explain what the library does and its purpose - If the library relates to other libraries in the monorepo (e.g., uses ocaml-requests for HTTP, complements ocaml-imap, etc.), mention those relationships with links - Example: Instead of "Initial import of ocaml-jmap library", write "OCaml implementation of the JMAP protocol — a modern, JSON-based alternative to IMAP for email access. Complements the existing [ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git) library" Here is the changelog to refine: %s Output ONLY the refined markdown, no explanation or preamble.|} markdown in let options = Claude.Options.default |> Claude.Options.with_max_turns 1 in let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in Claude.Client.query client prompt; let responses = Claude.Client.receive_all client in let result = ref None in List.iter (function | Claude.Response.Complete c -> ( match Claude.Response.Complete.result_text c with | Some text -> result := Some (Ok text) | None -> result := Some (Ok markdown) (* fallback to original *)) | Claude.Response.Error e -> result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e))) | _ -> ()) responses; match !result with | Some r -> r | None -> Ok markdown (* fallback to original *) (* Simple string containment check *) let string_contains_s haystack needle = let hlen = String.length haystack in let nlen = String.length needle in if nlen > hlen then false else begin let rec check i = if i > hlen - nlen then false else if String.sub haystack i nlen = needle then true else check (i + 1) in check 0 end (* Infer change type from summary text *) let infer_change_type summary = let summary_lower = String.lowercase_ascii summary in if String.starts_with ~prefix:"initial import" summary_lower || String.starts_with ~prefix:"added as subtree" summary_lower || String.starts_with ~prefix:"added" summary_lower && String.ends_with ~suffix:"library" summary_lower then Changes_aggregated.New_library else if List.exists (fun kw -> string_contains_s summary_lower kw) [ "fix"; "bugfix"; "bug fix"; "repair"; "patch"; "resolve"; "correct" ] then Changes_aggregated.Bugfix else if List.exists (fun kw -> string_contains_s summary_lower kw) [ "refactor"; "cleanup"; "clean up"; "reorganize"; "restructure"; "simplify"; ] then Changes_aggregated.Refactor else if List.exists (fun kw -> string_contains_s summary_lower kw) [ "doc"; "documentation"; "readme"; "comment"; "tutorial"; "guide" ] then Changes_aggregated.Documentation else if List.exists (fun kw -> string_contains_s summary_lower kw) [ "add"; "new"; "feature"; "implement"; "support"; "introduce"; "enable" ] then Changes_aggregated.Feature else Changes_aggregated.Unknown (** Generate an aggregated daily file from individual daily json files. This creates a YYYYMMDD.json file in the .changes directory. *) let generate_aggregated ~fs ~monorepo ~date ~git_head ~now = let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in (* List all *-.json files (new per-day format) *) let files = try Eio.Path.read_dir changes_dir with Eio.Io _ -> [] in (* Match files like "-2026-01-19.json" for the given date *) let date_suffix = "-" ^ date ^ ".json" in let date_suffix_len = String.length date_suffix in let daily_files = List.filter (fun f -> String.ends_with ~suffix:date_suffix f && String.length f > date_suffix_len) files in (* Load all daily files for this date and collect entries *) let entries = List.concat_map (fun filename -> (* Extract repo name: filename is "-.json" *) let repo_name = String.sub filename 0 (String.length filename - date_suffix_len) in let path = Eio.Path.(changes_dir / filename) in try let content = Eio.Path.load path in match Jsont_bytesrw.decode_string daily_changes_file_jsont content with | Ok dcf -> List.filter_map (fun (e : daily_entry) -> if e.changes <> [] then Some (repo_name, e) else None) dcf.entries | Error _ -> [] with Eio.Io _ -> []) daily_files in (* Convert to aggregated format *) let agg_entries = List.map (fun (repo_name, (e : daily_entry)) -> let change_type = infer_change_type e.summary in Changes_aggregated. { repository = repo_name; hour = e.hour; timestamp = e.timestamp; summary = e.summary; changes = e.changes; commit_range = { from_hash = e.commit_range.from_hash; to_hash = e.commit_range.to_hash; count = e.commit_range.count; }; contributors = e.contributors; repo_url = e.repo_url; change_type; }) entries in (* Collect all unique authors *) let authors = entries |> List.concat_map (fun (_, (e : daily_entry)) -> e.contributors) |> List.sort_uniq String.compare in (* Create the aggregated structure *) let aggregated : Changes_aggregated.t = { date; generated_at = now; git_head; entries = agg_entries; authors } in (* Save to YYYYMMDD.json *) let changes_dir_fpath = Fpath.(v (Fpath.to_string monorepo) / ".changes") in Changes_aggregated.save ~fs ~changes_dir:changes_dir_fpath aggregated