···397397(* Changes command *)
398398399399let changes_cmd =
400400- let doc = "Generate weekly changelog entries using Claude AI" in
400400+ let doc = "Generate changelog entries using Claude AI" in
401401 let man =
402402 [
403403 `S Manpage.s_description;
404404 `P "Analyzes git commit history and generates user-facing changelogs.";
405405 `P
406406- "For each package, creates/updates a CHANGES.json file with weekly \
407407- entries. Also generates an aggregated CHANGES.md at the monorepo root.";
408408- `P "Each weekly entry includes:";
406406+ "By default, generates weekly entries. Use --daily to generate daily \
407407+ entries instead.";
408408+ `P
409409+ "Changes are stored in the .changes directory at the monorepo root:";
410410+ `I (".changes/<repo>.json", "Weekly changelog entries");
411411+ `I (".changes/<repo>-daily.json", "Daily changelog entries");
412412+ `P
413413+ "Also generates aggregated markdown files at the monorepo root:";
414414+ `I ("CHANGES.md", "Aggregated weekly changelog");
415415+ `I ("DAILY-CHANGES.md", "Aggregated daily changelog");
416416+ `P "Each entry includes:";
409417 `I ("summary", "A one-line summary of the most important change");
410418 `I ("changes", "Up to 5 bullet points describing user-facing changes");
411419 `I ("commit_range", "The range of commits included in the entry");
···413421 "Claude AI analyzes commits and generates changelog text focused on \
414422 user-facing changes. Internal refactoring, CI tweaks, and typo fixes \
415423 are automatically filtered out.";
424424+ `P
425425+ "Repositories with no user-facing changes will have blank entries \
426426+ (empty summary and changes) rather than 'no changes' text.";
416427 ]
417428 in
418429 let info = Cmd.info "changes" ~doc ~man in
430430+ let daily =
431431+ let doc = "Generate daily changelog entries instead of weekly" in
432432+ Arg.(value & flag & info [ "daily"; "d" ] ~doc)
433433+ in
419434 let weeks =
420420- let doc = "Number of past weeks to analyze (default: 1, current week only)" in
435435+ let doc = "Number of past weeks to analyze (default: 1, current week only). Ignored if --daily is set." in
421436 Arg.(value & opt int 1 & info [ "w"; "weeks" ] ~doc)
422437 in
438438+ let days =
439439+ let doc = "Number of past days to analyze when using --daily (default: 1, today only)" in
440440+ Arg.(value & opt int 1 & info [ "days" ] ~doc)
441441+ in
423442 let history =
424424- let doc = "Number of recent weeks to include in CHANGES.md (default: 12)" in
443443+ let doc = "Number of recent entries to include in aggregated markdown (default: 12 for weekly, 30 for daily)" in
425444 Arg.(value & opt int 12 & info [ "history" ] ~doc)
426445 in
427446 let dry_run =
428447 let doc = "Preview changes without writing files" in
429448 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
430449 in
431431- let run config_file package weeks history dry_run () =
450450+ let run config_file package daily weeks days history dry_run () =
432451 Eio_main.run @@ fun env ->
433452 with_config env config_file @@ fun config ->
434453 let fs = Eio.Stdenv.fs env in
435454 let proc = Eio.Stdenv.process_mgr env in
436455 let clock = Eio.Stdenv.clock env in
437437- match
438438- Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run
439439- ()
440440- with
456456+ let result =
457457+ if daily then begin
458458+ (* Use 30 as default history for daily if not explicitly set *)
459459+ let history = if history = 12 then 30 else history in
460460+ Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run ()
461461+ end
462462+ else
463463+ Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run ()
464464+ in
465465+ match result with
441466 | Ok () ->
442467 if dry_run then Fmt.pr "Dry run complete.@."
443443- else Fmt.pr "Changelog updated.@.";
468468+ else if daily then Fmt.pr "Daily changelog updated.@."
469469+ else Fmt.pr "Weekly changelog updated.@.";
444470 `Ok ()
445471 | Error e ->
446472 Fmt.epr "Error: %a@." Monopam.pp_error e;
···449475 Cmd.v info
450476 Term.(
451477 ret
452452- (const run $ config_file_arg $ package_arg $ weeks $ history $ dry_run
478478+ (const run $ config_file_arg $ package_arg $ daily $ weeks $ days $ history $ dry_run
453479 $ logging_term))
454480455481(* Main command group *)
+375-34
monopam/lib/changes.ml
···11(** Changelog generation for monopam.
2233- This module handles generating weekly changelog entries using Claude AI
44- to analyze git commit history and produce user-facing change summaries. *)
33+ This module handles generating weekly and daily changelog entries using Claude AI
44+ to analyze git commit history and produce user-facing change summaries.
55+66+ Changes are stored in a .changes directory at the monorepo root:
77+ - .changes/<repo_name>.json - weekly changelog entries
88+ - .changes/<repo_name>-daily.json - daily changelog entries *)
59610type commit_range = {
711 from_hash : string;
···1721 commit_range : commit_range;
1822}
19232424+type daily_entry = {
2525+ date : string; (* ISO date YYYY-MM-DD *)
2626+ summary : string; (* One-line summary *)
2727+ changes : string list; (* Bullet points *)
2828+ commit_range : commit_range;
2929+ contributors : string list; (* List of contributors for this entry *)
3030+ repo_url : string option; (* Upstream repository URL *)
3131+}
3232+2033type changes_file = {
2134 repository : string;
2235 entries : weekly_entry list;
2336}
24373838+type daily_changes_file = {
3939+ repository : string;
4040+ entries : daily_entry list;
4141+}
4242+4343+(** Mode for changelog generation *)
4444+type mode = Weekly | Daily
4545+2546(* Jsont codecs *)
26472748let commit_range_jsont =
···3253 |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count)
3354 |> Jsont.Object.finish
34553535-let weekly_entry_jsont =
3636- let make week_start week_end summary changes commit_range =
5656+let weekly_entry_jsont : weekly_entry Jsont.t =
5757+ let make week_start week_end summary changes commit_range : weekly_entry =
3758 { week_start; week_end; summary; changes; commit_range }
3859 in
3960 Jsont.Object.map ~kind:"weekly_entry" make
4040- |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun e -> e.week_start)
4141- |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun e -> e.week_end)
4242- |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary)
4343- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes)
4444- |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range)
6161+ |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_start)
6262+ |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_end)
6363+ |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : weekly_entry) -> e.summary)
6464+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : weekly_entry) -> e.changes)
6565+ |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : weekly_entry) -> e.commit_range)
4566 |> Jsont.Object.finish
46674747-let changes_file_jsont =
4848- let make repository entries = { repository; entries } in
6868+let changes_file_jsont : changes_file Jsont.t =
6969+ let make repository entries : changes_file = { repository; entries } in
4970 Jsont.Object.map ~kind:"changes_file" make
5050- |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun f -> f.repository)
5151- |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont) ~enc:(fun f -> f.entries)
7171+ |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : changes_file) -> f.repository)
7272+ |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont) ~enc:(fun (f : changes_file) -> f.entries)
7373+ |> Jsont.Object.finish
7474+7575+let daily_entry_jsont : daily_entry Jsont.t =
7676+ let make date summary changes commit_range contributors repo_url : daily_entry =
7777+ { date; summary; changes; commit_range; contributors; repo_url }
7878+ in
7979+ Jsont.Object.map ~kind:"daily_entry" make
8080+ |> Jsont.Object.mem "date" Jsont.string ~enc:(fun (e : daily_entry) -> e.date)
8181+ |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) -> e.summary)
8282+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : daily_entry) -> e.changes)
8383+ |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : daily_entry) -> e.commit_range)
8484+ |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun (e : daily_entry) -> e.contributors)
8585+ |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun (e : daily_entry) -> e.repo_url)
8686+ |> Jsont.Object.finish
8787+8888+let daily_changes_file_jsont : daily_changes_file Jsont.t =
8989+ let make repository entries : daily_changes_file = { repository; entries } in
9090+ Jsont.Object.map ~kind:"daily_changes_file" make
9191+ |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : daily_changes_file) -> f.repository)
9292+ |> Jsont.Object.mem "entries" (Jsont.list daily_entry_jsont) ~enc:(fun (f : daily_changes_file) -> f.entries)
5293 |> Jsont.Object.finish
53945495(* File I/O *)
55965656-let load ~fs path =
5757- let file_path = Eio.Path.(fs / Fpath.to_string path / "CHANGES.json") in
9797+(* Helper to ensure .changes directory exists *)
9898+let ensure_changes_dir ~fs monorepo =
9999+ let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
100100+ match Eio.Path.kind ~follow:true changes_dir with
101101+ | `Directory -> ()
102102+ | _ -> Eio.Path.mkdir ~perm:0o755 changes_dir
103103+ | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 changes_dir
104104+105105+(* Load weekly changes from .changes/<repo>.json in monorepo *)
106106+let load ~fs ~monorepo repo_name =
107107+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ ".json")) in
58108 match Eio.Path.kind ~follow:true file_path with
59109 | `Regular_file -> (
60110 let content = Eio.Path.load file_path in
61111 match Jsont_bytesrw.decode_string changes_file_jsont content with
62112 | Ok cf -> Ok cf
6363- | Error e -> Error (Format.sprintf "Failed to parse CHANGES.json: %s" e))
6464- | _ -> Ok { repository = Fpath.basename path; entries = [] }
6565- | exception Eio.Io _ -> Ok { repository = Fpath.basename path; entries = [] }
113113+ | Error e -> Error (Format.sprintf "Failed to parse %s.json: %s" repo_name e))
114114+ | _ -> Ok { repository = repo_name; entries = [] }
115115+ | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
661166767-let save ~fs cf path =
6868- let file_path = Eio.Path.(fs / Fpath.to_string path / "CHANGES.json") in
117117+(* Save weekly changes to .changes/<repo>.json in monorepo *)
118118+let save ~fs ~monorepo (cf : changes_file) =
119119+ ensure_changes_dir ~fs monorepo;
120120+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ ".json")) in
69121 match Jsont_bytesrw.encode_string ~format:Jsont.Indent changes_file_jsont cf with
70122 | Ok content ->
71123 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
72124 Ok ()
7373- | Error e -> Error (Format.sprintf "Failed to encode CHANGES.json: %s" e)
125125+ | Error e -> Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e)
126126+127127+(* Load daily changes from .changes/<repo>-daily.json in monorepo *)
128128+let load_daily ~fs ~monorepo repo_name =
129129+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ "-daily.json")) in
130130+ match Eio.Path.kind ~follow:true file_path with
131131+ | `Regular_file -> (
132132+ let content = Eio.Path.load file_path in
133133+ match Jsont_bytesrw.decode_string daily_changes_file_jsont content with
134134+ | Ok cf -> Ok cf
135135+ | Error e -> Error (Format.sprintf "Failed to parse %s-daily.json: %s" repo_name e))
136136+ | _ -> Ok { repository = repo_name; entries = [] }
137137+ | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
138138+139139+(* Save daily changes to .changes/<repo>-daily.json in monorepo *)
140140+let save_daily ~fs ~monorepo (cf : daily_changes_file) =
141141+ ensure_changes_dir ~fs monorepo;
142142+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ "-daily.json")) in
143143+ match Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf with
144144+ | Ok content ->
145145+ Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
146146+ Ok ()
147147+ | Error e -> Error (Format.sprintf "Failed to encode %s-daily.json: %s" cf.repository e)
7414875149(* Markdown generation *)
761507777-let to_markdown cf =
151151+let to_markdown (cf : changes_file) =
78152 let buf = Buffer.create 1024 in
79153 Buffer.add_string buf (Printf.sprintf "# %s Changelog\n\n" cf.repository);
8080- List.iter (fun entry ->
154154+ List.iter (fun (entry : weekly_entry) ->
81155 Buffer.add_string buf (Printf.sprintf "## Week of %s to %s\n\n" entry.week_start entry.week_end);
82156 Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
83157 List.iter (fun change ->
···87161 cf.entries;
88162 Buffer.contents buf
891639090-let aggregate ~history cfs =
164164+let aggregate ~history (cfs : changes_file list) =
91165 (* Collect all entries from all files, tagged with repository *)
92166 let all_entries =
9393- List.concat_map (fun cf ->
9494- List.map (fun e -> (cf.repository, e)) cf.entries)
167167+ List.concat_map (fun (cf : changes_file) ->
168168+ List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
95169 cfs
96170 in
97171 (* Sort by week_start descending *)
9898- let sorted = List.sort (fun (_, e1) (_, e2) ->
172172+ let sorted = List.sort (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
99173 String.compare e2.week_start e1.week_start) all_entries
100174 in
101175 (* Group by week *)
···103177 | [] ->
104178 if current_group <> [] then (current_week, List.rev current_group) :: acc
105179 else acc
106106- | (repo, entry) :: rest ->
180180+ | (repo, (entry : weekly_entry)) :: rest ->
107181 let week_key = entry.week_start ^ " to " ^ entry.week_end in
108182 if current_week = "" || current_week = week_key then
109183 group_by_week acc week_key ((repo, entry) :: current_group) rest
···126200 Buffer.add_string buf "# Changelog\n\n";
127201 List.iter (fun (week_key, entries) ->
128202 Buffer.add_string buf (Printf.sprintf "## Week of %s\n\n" week_key);
129129- List.iter (fun (repo, entry) ->
203203+ List.iter (fun (repo, (entry : weekly_entry)) ->
130204 Buffer.add_string buf (Printf.sprintf "### %s\n" repo);
131205 Buffer.add_string buf (Printf.sprintf "%s\n" entry.summary);
132206 List.iter (fun change ->
···195269 let (y, m, d), _ = Ptime.to_date_time t in
196270 week_of_date (y, m, d)
197271198198-let has_week cf ~week_start =
199199- List.exists (fun e -> e.week_start = week_start) cf.entries
272272+let has_week (cf : changes_file) ~week_start =
273273+ List.exists (fun (e : weekly_entry) -> e.week_start = week_start) cf.entries
274274+275275+let date_of_ptime t =
276276+ let (y, m, d), _ = Ptime.to_date_time t in
277277+ format_date (y, m, d)
278278+279279+let has_day (cf : daily_changes_file) ~date =
280280+ List.exists (fun (e : daily_entry) -> e.date = date) cf.entries
281281+282282+(* Aggregate daily changes into DAILY-CHANGES.md *)
283283+let aggregate_daily ~history (cfs : daily_changes_file list) =
284284+ (* Collect all entries from all files, tagged with repository *)
285285+ let all_entries =
286286+ List.concat_map (fun (cf : daily_changes_file) ->
287287+ List.map (fun (e : daily_entry) -> (cf.repository, e)) cf.entries)
288288+ cfs
289289+ in
290290+ (* Sort by date descending *)
291291+ let sorted = List.sort (fun (_, (e1 : daily_entry)) (_, (e2 : daily_entry)) ->
292292+ String.compare e2.date e1.date) all_entries
293293+ in
294294+ (* Group by date *)
295295+ let rec group_by_date acc current_date current_group = function
296296+ | [] ->
297297+ if current_group <> [] then (current_date, List.rev current_group) :: acc
298298+ else acc
299299+ | (repo, (entry : daily_entry)) :: rest ->
300300+ if current_date = "" || current_date = entry.date then
301301+ group_by_date acc entry.date ((repo, entry) :: current_group) rest
302302+ else
303303+ group_by_date
304304+ ((current_date, List.rev current_group) :: acc)
305305+ entry.date
306306+ [(repo, entry)]
307307+ rest
308308+ in
309309+ let grouped = List.rev (group_by_date [] "" [] sorted) in
310310+ (* Take only the requested number of days *)
311311+ let limited =
312312+ if history > 0 then
313313+ List.filteri (fun i _ -> i < history) grouped
314314+ else grouped
315315+ in
316316+ (* Generate markdown - only include repos with actual changes *)
317317+ let buf = Buffer.create 4096 in
318318+ Buffer.add_string buf "# Daily Changelog\n\n";
319319+ List.iter (fun (date, entries) ->
320320+ (* Filter out entries with empty changes - these are repos with no changes *)
321321+ let entries_with_changes = List.filter (fun (_, (entry : daily_entry)) ->
322322+ entry.changes <> []) entries
323323+ in
324324+ if entries_with_changes <> [] then begin
325325+ Buffer.add_string buf (Printf.sprintf "## %s\n\n" date);
326326+ List.iter (fun (repo, (entry : daily_entry)) ->
327327+ (* Format repo name with link if URL available *)
328328+ let repo_header = match entry.repo_url with
329329+ | Some url -> Printf.sprintf "[%s](%s)" repo url
330330+ | None -> repo
331331+ in
332332+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_header);
333333+ Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
334334+ List.iter (fun change ->
335335+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
336336+ entry.changes;
337337+ (* Add contributors if any *)
338338+ if entry.contributors <> [] then begin
339339+ let contributors_str = String.concat ", " entry.contributors in
340340+ Buffer.add_string buf (Printf.sprintf "\n*Contributors: %s*\n" contributors_str)
341341+ end;
342342+ Buffer.add_string buf "\n")
343343+ entries_with_changes
344344+ end)
345345+ limited;
346346+ Buffer.contents buf
200347201348(* Claude prompt generation *)
202349203203-let generate_prompt ~repository ~week_start ~week_end commits =
350350+let generate_weekly_prompt ~repository ~week_start ~week_end commits =
204351 let buf = Buffer.create 4096 in
205352 Buffer.add_string buf (Printf.sprintf
206353 "You are analyzing git commits for the OCaml library \"%s\".\n" repository);
···226373 - Typo fixes in code comments
227374 - Dependency bumps (unless they add features)
228375229229-2. If there are NO user-facing changes, respond with exactly: NO_CHANGES
376376+2. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty
377377+ summary and empty changes array. Do NOT write "no changes" or similar text.
378378+ Example for no changes: {"summary": "", "changes": []}
2303792313803. Otherwise, respond in this exact JSON format:
232381{
···247396|};
248397 Buffer.contents buf
249398399399+let generate_daily_prompt ~repository ~date commits =
400400+ let buf = Buffer.create 4096 in
401401+ Buffer.add_string buf (Printf.sprintf
402402+ "You are analyzing git commits for the OCaml library \"%s\".\n" repository);
403403+ Buffer.add_string buf (Printf.sprintf
404404+ "Generate a user-facing changelog entry for %s.\n\n" date);
405405+ Buffer.add_string buf "## Commits today:\n\n";
406406+ List.iter (fun (commit : Git.log_entry) ->
407407+ Buffer.add_string buf (Printf.sprintf "### %s by %s (%s)\n"
408408+ (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
409409+ commit.author commit.date);
410410+ Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
411411+ if commit.body <> "" then begin
412412+ Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
413413+ end;
414414+ Buffer.add_string buf "---\n\n")
415415+ commits;
416416+ Buffer.add_string buf {|## Instructions:
417417+418418+1. Focus on USER-FACING changes only. Skip:
419419+ - Internal refactoring with no API impact
420420+ - CI/build system tweaks
421421+ - Typo fixes in code comments
422422+ - Dependency bumps (unless they add features)
423423+424424+2. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty
425425+ summary and empty changes array. Do NOT write "no changes" or similar text.
426426+ Example for no changes: {"summary": "", "changes": []}
427427+428428+3. Otherwise, respond in this exact JSON format:
429429+{
430430+ "summary": "One sentence describing the most important change",
431431+ "changes": [
432432+ "First user-facing change as a bullet point",
433433+ "Second change",
434434+ "..."
435435+ ]
436436+}
437437+438438+4. Write for developers using this library. Be:
439439+ - Concise (max 80 chars per bullet)
440440+ - Specific (mention function/module names)
441441+ - Action-oriented (start with verbs: Added, Fixed, Improved, Removed)
442442+443443+5. Maximum 5 bullet points. Group related changes if needed.
444444+|};
445445+ Buffer.contents buf
446446+447447+(* Backwards compatibility *)
448448+let generate_prompt = generate_weekly_prompt
449449+250450(* Response parsing *)
251451252452type claude_response = {
···263463264464let parse_claude_response text =
265465 let text = String.trim text in
466466+ (* Legacy support for NO_CHANGES response *)
266467 if text = "NO_CHANGES" then Ok None
267468 else
268469 match Jsont_bytesrw.decode_string claude_response_jsont text with
269269- | Ok r -> Ok (Some r)
470470+ | Ok r ->
471471+ (* Treat empty summary and changes as no changes *)
472472+ if r.summary = "" && r.changes = [] then Ok None
473473+ else Ok (Some r)
270474 | Error e -> Error (Format.sprintf "Failed to parse Claude response: %s" e)
271475272476(* Main analysis function *)
···343547 | Some r -> r
344548 | None -> Ok None
345549 end
550550+551551+(* Daily analysis function *)
552552+let analyze_commits_daily
553553+ ~sw
554554+ ~process_mgr
555555+ ~clock
556556+ ~repository
557557+ ~date
558558+ commits =
559559+ if commits = [] then Ok None
560560+ else begin
561561+ let prompt = generate_daily_prompt ~repository ~date commits in
562562+563563+ (* Create Claude options with structured output *)
564564+ let output_schema =
565565+ let open Jsont in
566566+ Object ([
567567+ (("type", Meta.none), String ("object", Meta.none));
568568+ (("properties", Meta.none), Object ([
569569+ (("summary", Meta.none), Object ([
570570+ (("type", Meta.none), String ("string", Meta.none));
571571+ ], Meta.none));
572572+ (("changes", Meta.none), Object ([
573573+ (("type", Meta.none), String ("array", Meta.none));
574574+ (("items", Meta.none), Object ([
575575+ (("type", Meta.none), String ("string", Meta.none));
576576+ ], Meta.none));
577577+ ], Meta.none));
578578+ ], Meta.none));
579579+ (("required", Meta.none), Array ([
580580+ String ("summary", Meta.none);
581581+ String ("changes", Meta.none);
582582+ ], Meta.none));
583583+ ], Meta.none)
584584+ in
585585+ let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in
586586+ let options =
587587+ Claude.Options.default
588588+ |> Claude.Options.with_output_format output_format
589589+ |> Claude.Options.with_max_turns 1
590590+ in
591591+592592+ let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
593593+ Claude.Client.query client prompt;
594594+595595+ let responses = Claude.Client.receive_all client in
596596+ let result = ref None in
597597+ List.iter (function
598598+ | Claude.Response.Complete c -> (
599599+ match Claude.Response.Complete.structured_output c with
600600+ | Some json -> (
601601+ match Jsont.Json.decode claude_response_jsont json with
602602+ | Ok r ->
603603+ (* Treat empty response as no changes *)
604604+ if r.summary = "" && r.changes = [] then
605605+ result := Some (Ok None)
606606+ else
607607+ result := Some (Ok (Some r))
608608+ | Error e ->
609609+ result := Some (Error (Format.sprintf "Failed to decode response: %s" e)))
610610+ | None ->
611611+ (* Try to get text and parse it as fallback *)
612612+ match Claude.Response.Complete.result_text c with
613613+ | Some text -> result := Some (parse_claude_response text)
614614+ | None -> result := Some (Ok None))
615615+ | Claude.Response.Text t ->
616616+ let text = Claude.Response.Text.content t in
617617+ if String.trim text = "NO_CHANGES" then
618618+ result := Some (Ok None)
619619+ | Claude.Response.Error e ->
620620+ result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e)))
621621+ | _ -> ())
622622+ responses;
623623+624624+ match !result with
625625+ | Some r -> r
626626+ | None -> Ok None
627627+ end
628628+629629+(* Refine daily changelog markdown to be more narrative *)
630630+let refine_daily_changelog
631631+ ~sw
632632+ ~process_mgr
633633+ ~clock
634634+ markdown =
635635+ let prompt = Printf.sprintf {|You are editing a daily changelog for an OCaml monorepo.
636636+637637+Your task is to refine the following changelog to be:
638638+1. More narrative and human-readable - write it as a daily update that developers will want to read
639639+2. Grouped by related changes - if multiple repos have related changes, group them together
640640+3. Succinct but complete - don't lose any information, but make it more concise
641641+4. Well-ordered - put the most significant changes first
642642+643643+Keep the markdown format with:
644644+- A main heading for each date
645645+- 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"
646646+- Bullet points for individual changes
647647+- Preserve all contributor attributions (format: — *Contributor Name*)
648648+- 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)
649649+- Format each bullet as: **[repo-name](url)**: Description — *Contributors* (if any)
650650+651651+IMPORTANT: For "initial import" or "added as subtree" entries:
652652+- Put these in a dedicated "New Libraries" section
653653+- Expand the description to explain what the library does and its purpose
654654+- 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
655655+- 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"
656656+657657+Here is the changelog to refine:
658658+659659+%s
660660+661661+Output ONLY the refined markdown, no explanation or preamble.|} markdown
662662+ in
663663+664664+ let options =
665665+ Claude.Options.default
666666+ |> Claude.Options.with_max_turns 1
667667+ in
668668+669669+ let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
670670+ Claude.Client.query client prompt;
671671+672672+ let responses = Claude.Client.receive_all client in
673673+ let result = ref None in
674674+ List.iter (function
675675+ | Claude.Response.Complete c -> (
676676+ match Claude.Response.Complete.result_text c with
677677+ | Some text -> result := Some (Ok text)
678678+ | None -> result := Some (Ok markdown)) (* fallback to original *)
679679+ | Claude.Response.Error e ->
680680+ result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e)))
681681+ | _ -> ())
682682+ responses;
683683+684684+ match !result with
685685+ | Some r -> r
686686+ | None -> Ok markdown (* fallback to original *)
+107-15
monopam/lib/changes.mli
···11(** Changelog generation for monopam.
2233- This module handles generating weekly changelog entries using Claude AI
44- to analyze git commit history and produce user-facing change summaries. *)
33+ This module handles generating weekly and daily changelog entries using Claude AI
44+ to analyze git commit history and produce user-facing change summaries.
55+66+ Changes are stored in a .changes directory at the monorepo root:
77+ - .changes/<repo_name>.json - weekly changelog entries
88+ - .changes/<repo_name>-daily.json - daily changelog entries *)
59610(** {1 Types} *)
711···2125}
2226(** A single week's changelog entry. *)
23272828+type daily_entry = {
2929+ date : string; (** ISO date YYYY-MM-DD *)
3030+ summary : string; (** One-line summary *)
3131+ changes : string list; (** Bullet points *)
3232+ commit_range : commit_range;
3333+ contributors : string list; (** List of contributors for this entry *)
3434+ repo_url : string option; (** Upstream repository URL *)
3535+}
3636+(** A single day's changelog entry. *)
3737+2438type changes_file = {
2539 repository : string;
2640 entries : weekly_entry list;
2741}
2828-(** Contents of a CHANGES.json file for a repository. *)
4242+(** Contents of a weekly changes JSON file for a repository. *)
4343+4444+type daily_changes_file = {
4545+ repository : string;
4646+ entries : daily_entry list;
4747+}
4848+(** Contents of a daily changes JSON file for a repository. *)
4949+5050+(** Mode for changelog generation. *)
5151+type mode = Weekly | Daily
29523053(** {1 JSON Codecs} *)
3154···3659(** JSON codec for weekly entries. *)
37603861val changes_file_jsont : changes_file Jsont.t
3939-(** JSON codec for changes files. *)
6262+(** JSON codec for weekly changes files. *)
6363+6464+val daily_entry_jsont : daily_entry Jsont.t
6565+(** JSON codec for daily entries. *)
6666+6767+val daily_changes_file_jsont : daily_changes_file Jsont.t
6868+(** JSON codec for daily changes files. *)
40694170(** {1 File I/O} *)
42714343-val load : fs:_ Eio.Path.t -> Fpath.t -> (changes_file, string) result
4444-(** [load ~fs path] loads a CHANGES.json from the given directory.
7272+val load : fs:_ Eio.Path.t -> monorepo:Fpath.t -> string -> (changes_file, string) result
7373+(** [load ~fs ~monorepo repo_name] loads weekly changes from .changes/<repo_name>.json.
4574 Returns an empty changes file if the file does not exist. *)
46754747-val save : fs:_ Eio.Path.t -> changes_file -> Fpath.t -> (unit, string) result
4848-(** [save ~fs cf path] saves the changes file to CHANGES.json in the given directory. *)
7676+val save : fs:_ Eio.Path.t -> monorepo:Fpath.t -> changes_file -> (unit, string) result
7777+(** [save ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>.json. *)
7878+7979+val load_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> string -> (daily_changes_file, string) result
8080+(** [load_daily ~fs ~monorepo repo_name] loads daily changes from .changes/<repo_name>-daily.json.
8181+ Returns an empty changes file if the file does not exist. *)
8282+8383+val save_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> daily_changes_file -> (unit, string) result
8484+(** [save_daily ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>-daily.json. *)
49855086(** {1 Markdown Generation} *)
51875288val to_markdown : changes_file -> string
5353-(** [to_markdown cf] generates markdown from a single changes file. *)
8989+(** [to_markdown cf] generates markdown from a single weekly changes file. *)
54905591val aggregate : history:int -> changes_file list -> string
5656-(** [aggregate ~history cfs] generates combined markdown from multiple changes files.
9292+(** [aggregate ~history cfs] generates combined markdown from multiple weekly changes files.
5793 @param history Number of weeks to include (0 for all) *)
58945959-(** {1 Week Calculation} *)
9595+val aggregate_daily : history:int -> daily_changes_file list -> string
9696+(** [aggregate_daily ~history cfs] generates combined markdown from multiple daily changes files.
9797+ Only includes repos with actual changes (filters out empty entries).
9898+ @param history Number of days to include (0 for all) *)
9999+100100+(** {1 Date Calculation} *)
101101+102102+val format_date : int * int * int -> string
103103+(** [format_date (year, month, day)] formats a date as YYYY-MM-DD. *)
6010461105val week_of_date : int * int * int -> string * string
62106(** [week_of_date (year, month, day)] returns (week_start, week_end) as ISO date strings.
···6410865109val week_of_ptime : Ptime.t -> string * string
66110(** [week_of_ptime t] returns (week_start, week_end) for the given timestamp. *)
111111+112112+val date_of_ptime : Ptime.t -> string
113113+(** [date_of_ptime t] returns the date as YYYY-MM-DD for the given timestamp. *)
6711468115val has_week : changes_file -> week_start:string -> bool
69116(** [has_week cf ~week_start] returns true if the changes file already has an entry
70117 for the week starting on the given date. *)
71118119119+val has_day : daily_changes_file -> date:string -> bool
120120+(** [has_day cf ~date] returns true if the daily changes file already has an entry
121121+ for the given date. *)
122122+72123(** {1 Claude Integration} *)
7312474125type claude_response = {
···84135 Git.log_entry list ->
85136 string
86137(** [generate_prompt ~repository ~week_start ~week_end commits] creates the prompt
8787- to send to Claude for changelog generation. *)
138138+ to send to Claude for weekly changelog generation. *)
139139+140140+val generate_weekly_prompt :
141141+ repository:string ->
142142+ week_start:string ->
143143+ week_end:string ->
144144+ Git.log_entry list ->
145145+ string
146146+(** [generate_weekly_prompt ~repository ~week_start ~week_end commits] creates the prompt
147147+ to send to Claude for weekly changelog generation. *)
148148+149149+val generate_daily_prompt :
150150+ repository:string ->
151151+ date:string ->
152152+ Git.log_entry list ->
153153+ string
154154+(** [generate_daily_prompt ~repository ~date commits] creates the prompt
155155+ to send to Claude for daily changelog generation. *)
8815689157val parse_claude_response : string -> (claude_response option, string) result
90158(** [parse_claude_response text] parses Claude's response.
9191- Returns [Ok None] if the response is "NO_CHANGES".
9292- Returns [Ok (Some r)] if valid JSON was parsed.
159159+ Returns [Ok None] if the response is empty (blank summary and changes) or "NO_CHANGES".
160160+ Returns [Ok (Some r)] if valid JSON was parsed with actual changes.
93161 Returns [Error msg] if parsing failed. *)
9416295163val analyze_commits :
···102170 Git.log_entry list ->
103171 (claude_response option, string) result
104172(** [analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end commits]
105105- sends commits to Claude for analysis and returns the parsed response. *)
173173+ sends commits to Claude for weekly analysis and returns the parsed response. *)
174174+175175+val analyze_commits_daily :
176176+ sw:Eio.Switch.t ->
177177+ process_mgr:_ Eio.Process.mgr ->
178178+ clock:float Eio.Time.clock_ty Eio.Resource.t ->
179179+ repository:string ->
180180+ date:string ->
181181+ Git.log_entry list ->
182182+ (claude_response option, string) result
183183+(** [analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits]
184184+ sends commits to Claude for daily analysis and returns the parsed response. *)
185185+186186+val refine_daily_changelog :
187187+ sw:Eio.Switch.t ->
188188+ process_mgr:_ Eio.Process.mgr ->
189189+ clock:float Eio.Time.clock_ty Eio.Resource.t ->
190190+ string ->
191191+ (string, string) result
192192+(** [refine_daily_changelog ~sw ~process_mgr ~clock markdown] sends the raw
193193+ daily changelog markdown through Claude to produce a more narrative,
194194+ well-organized version. Groups related changes together and orders them
195195+ by significance. Ensures all repository names are formatted as markdown
196196+ links using the pattern [\[repo-name\](https://tangled.org/@anil.recoil.org/repo-name.git)].
197197+ Returns the refined markdown or the original on error. *)
···239239 @param remote Remote name (default: "origin")
240240 @param branch Branch to push (default: current branch) *)
241241242242+val set_push_url :
243243+ proc:_ Eio.Process.mgr ->
244244+ fs:Eio.Fs.dir_ty Eio.Path.t ->
245245+ ?remote:string ->
246246+ url:string ->
247247+ Fpath.t ->
248248+ (unit, error) result
249249+(** [set_push_url ~proc ~fs ?remote ~url path] sets the push URL for a remote.
250250+ This allows the fetch and push URLs to be different.
251251+252252+ @param remote Remote name (default: "origin")
253253+ @param url The URL to use for pushing *)
254254+255255+val get_push_url :
256256+ proc:_ Eio.Process.mgr ->
257257+ fs:Eio.Fs.dir_ty Eio.Path.t ->
258258+ ?remote:string ->
259259+ Fpath.t ->
260260+ string option
261261+(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote,
262262+ or [None] if not set or the remote doesn't exist.
263263+264264+ @param remote Remote name (default: "origin") *)
265265+242266(** {1 Commit History} *)
243267244268type log_entry = {
+218-9
monopam/lib/monopam.ml
···410410 Log.app (fun m -> m "Updated README.md with %d packages" (List.length pkgs))
411411 end
412412413413+(** Convert a clone URL to a push URL.
414414+ - GitHub HTTPS URLs are converted to SSH format
415415+ - Tangled URLs (tangled.org) are converted to git.recoil.org SSH format
416416+ - Other URLs are returned unchanged *)
417417+let url_to_push_url uri =
418418+ let scheme = Uri.scheme uri in
419419+ let host = Uri.host uri in
420420+ let path = Uri.path uri in
421421+ match (scheme, host) with
422422+ | Some ("https" | "http"), Some "github.com" ->
423423+ (* https://github.com/user/repo.git -> git@github.com:user/repo.git *)
424424+ let path = if String.length path > 0 && path.[0] = '/' then
425425+ String.sub path 1 (String.length path - 1)
426426+ else path in
427427+ Printf.sprintf "git@github.com:%s" path
428428+ | Some ("https" | "http"), Some "tangled.org" ->
429429+ (* https://tangled.org/anil.recoil.org/foo -> git@git.recoil.org:anil.recoil.org/foo *)
430430+ let path = if String.length path > 0 && path.[0] = '/' then
431431+ String.sub path 1 (String.length path - 1)
432432+ else path in
433433+ (* Strip .git suffix if present *)
434434+ let path = if String.ends_with ~suffix:".git" path then
435435+ String.sub path 0 (String.length path - 4)
436436+ else path in
437437+ Printf.sprintf "git@git.recoil.org:%s" path
438438+ | _ ->
439439+ (* Return original URL for other cases *)
440440+ Uri.to_string uri
441441+413442(* Normalize URL for comparison: extract scheme + host + path, strip trailing slashes *)
414443let normalize_url_for_comparison uri =
415444 let scheme = Option.value ~default:"" (Uri.scheme uri) in
···769798 Package.checkout_dir ~checkouts_root pkg
770799 in
771800 let branch = get_branch ~config pkg in
801801+ (* Configure push URL (rewriting GitHub/tangled URLs to SSH) *)
802802+ let push_url = url_to_push_url (Package.dev_repo pkg) in
772803 Log.info (fun m ->
773773- m "[%d/%d] Pushing %s to origin" i total
774774- (Package.repo_name pkg));
804804+ m "[%d/%d] Pushing %s to %s" i total
805805+ (Package.repo_name pkg) push_url);
806806+ (* Set the push URL for origin *)
807807+ (match Git.set_push_url ~proc ~fs:fs_t ~url:push_url checkout_dir with
808808+ | Ok () -> ()
809809+ | Error e ->
810810+ Log.warn (fun m ->
811811+ m "Failed to set push URL: %a" Git.pp_error e));
775812 match
776813 Git.push_remote ~proc ~fs:fs_t ~branch checkout_dir
777814 with
778815 | Ok () ->
779816 Log.app (fun m ->
780780- m " Pushed %s to origin/%s" (Package.repo_name pkg)
781781- branch);
817817+ m " Pushed %s to %s (%s)" (Package.repo_name pkg)
818818+ push_url branch);
782819 push_upstream (i + 1) rest
783820 | Error e -> Error (Git_error e))
784821 in
···848885 | [] -> Ok ()
849886 | pkg :: rest ->
850887 let repo_name = Package.repo_name pkg in
851851- let repo_path = Fpath.(monorepo / repo_name) in
852888853889 Log.info (fun m -> m "Processing %s" repo_name);
854890855855- (* Load existing CHANGES.json *)
856856- match Changes.load ~fs:fs_t repo_path with
891891+ (* Load existing changes from .changes/<repo>.json *)
892892+ match Changes.load ~fs:fs_t ~monorepo repo_name with
857893 | Error e -> Error (Claude_error e)
858894 | Ok changes_file ->
859895 (* Process each week *)
···936972 (* Save if changed and not dry run *)
937973 let save_result =
938974 if not dry_run && updated_cf.entries <> changes_file.entries then
939939- match Changes.save ~fs:fs_t updated_cf repo_path with
975975+ match Changes.save ~fs:fs_t ~monorepo updated_cf with
940976 | Error e -> Error (Claude_error e)
941977 | Ok () ->
942942- Log.app (fun m -> m "Saved CHANGES.json for %s" repo_name);
978978+ Log.app (fun m -> m "Saved .changes/%s.json" repo_name);
943979 Ok ()
944980 else Ok ()
945981 in
···961997 end;
962998 Ok ()
963999 end
10001000+10011001+(* Daily changes command - generate daily changelogs using Claude *)
10021002+10031003+let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) () =
10041004+ let fs_t = fs_typed fs in
10051005+ let monorepo = Config.Paths.monorepo config in
10061006+10071007+ (* Get current time *)
10081008+ let now = Eio.Time.now clock in
10091009+ let now_ptime = match Ptime.of_float_s now with
10101010+ | Some t -> t
10111011+ | None -> Ptime.v (0, 0L) (* fallback to epoch *)
10121012+ in
10131013+10141014+ match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
10151015+ | Error e -> Error e
10161016+ | Ok all_pkgs ->
10171017+ let repos = unique_repos all_pkgs in
10181018+ let repos = match package with
10191019+ | None -> repos
10201020+ | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
10211021+ in
10221022+ if repos = [] && package <> None then
10231023+ Error (Package_not_found (Option.get package))
10241024+ else begin
10251025+ Log.info (fun m -> m "Processing daily changelogs for %d repositories" (List.length repos));
10261026+10271027+ (* Process each repository *)
10281028+ let all_changes_files = ref [] in
10291029+ let rec process_repos = function
10301030+ | [] -> Ok ()
10311031+ | pkg :: rest ->
10321032+ let repo_name = Package.repo_name pkg in
10331033+10341034+ Log.info (fun m -> m "Processing %s" repo_name);
10351035+10361036+ (* Load existing daily changes from .changes/<repo>-daily.json *)
10371037+ match Changes.load_daily ~fs:fs_t ~monorepo repo_name with
10381038+ | Error e -> Error (Claude_error e)
10391039+ | Ok changes_file ->
10401040+ (* Process each day *)
10411041+ let rec process_days day_offset updated_cf =
10421042+ if day_offset >= days then Ok updated_cf
10431043+ else begin
10441044+ (* Calculate day boundaries *)
10451045+ let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
10461046+ let day_time = match Ptime.of_float_s (now -. offset_seconds) with
10471047+ | Some t -> t
10481048+ | None -> now_ptime
10491049+ in
10501050+ let date = Changes.date_of_ptime day_time in
10511051+10521052+ (* Skip if day already has an entry *)
10531053+ if Changes.has_day updated_cf ~date then begin
10541054+ Log.info (fun m -> m " Day %s already has entry, skipping" date);
10551055+ process_days (day_offset + 1) updated_cf
10561056+ end
10571057+ else begin
10581058+ (* Get commits for this day *)
10591059+ let since = date ^ " 00:00:00" in
10601060+ let until = date ^ " 23:59:59" in
10611061+ match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
10621062+ | Error e -> Error (Git_error e)
10631063+ | Ok commits ->
10641064+ if commits = [] then begin
10651065+ Log.info (fun m -> m " No commits for day %s" date);
10661066+ process_days (day_offset + 1) updated_cf
10671067+ end
10681068+ else begin
10691069+ Log.info (fun m -> m " Found %d commits for day %s" (List.length commits) date);
10701070+10711071+ if dry_run then begin
10721072+ Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s on %s"
10731073+ (List.length commits) repo_name date);
10741074+ process_days (day_offset + 1) updated_cf
10751075+ end
10761076+ else begin
10771077+ (* Analyze commits with Claude *)
10781078+ Eio.Switch.run @@ fun sw ->
10791079+ match Changes.analyze_commits_daily ~sw ~process_mgr:proc ~clock
10801080+ ~repository:repo_name ~date commits with
10811081+ | Error e -> Error (Claude_error e)
10821082+ | Ok None ->
10831083+ Log.info (fun m -> m " No user-facing changes for day %s" date);
10841084+ process_days (day_offset + 1) updated_cf
10851085+ | Ok (Some response) ->
10861086+ Log.app (fun m -> m " Generated changelog for %s on %s" repo_name date);
10871087+ (* Extract unique contributors from commits *)
10881088+ let contributors =
10891089+ commits
10901090+ |> List.map (fun (c : Git.log_entry) -> c.author)
10911091+ |> List.sort_uniq String.compare
10921092+ in
10931093+ (* Get repo URL from package dev_repo *)
10941094+ let repo_url =
10951095+ let uri = Package.dev_repo pkg in
10961096+ let url = Uri.to_string uri in
10971097+ (* Strip git+ prefix if present for display *)
10981098+ if String.starts_with ~prefix:"git+" url then
10991099+ Some (String.sub url 4 (String.length url - 4))
11001100+ else
11011101+ Some url
11021102+ in
11031103+ (* Create new entry *)
11041104+ let first_hash = (List.hd commits).Git.hash in
11051105+ let last_hash = (List.hd (List.rev commits)).Git.hash in
11061106+ let entry : Changes.daily_entry = {
11071107+ date;
11081108+ summary = response.Changes.summary;
11091109+ changes = response.Changes.changes;
11101110+ commit_range = {
11111111+ from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
11121112+ to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
11131113+ count = List.length commits;
11141114+ };
11151115+ contributors;
11161116+ repo_url;
11171117+ } in
11181118+ (* Add entry (sorted by date descending) *)
11191119+ let new_entries =
11201120+ entry :: updated_cf.Changes.entries
11211121+ |> List.sort (fun e1 e2 ->
11221122+ String.compare e2.Changes.date e1.Changes.date)
11231123+ in
11241124+ process_days (day_offset + 1)
11251125+ { updated_cf with entries = new_entries }
11261126+ end
11271127+ end
11281128+ end
11291129+ end
11301130+ in
11311131+ match process_days 0 changes_file with
11321132+ | Error e -> Error e
11331133+ | Ok updated_cf ->
11341134+ (* Save if changed and not dry run *)
11351135+ let save_result =
11361136+ if not dry_run && updated_cf.entries <> changes_file.entries then
11371137+ match Changes.save_daily ~fs:fs_t ~monorepo updated_cf with
11381138+ | Error e -> Error (Claude_error e)
11391139+ | Ok () ->
11401140+ Log.app (fun m -> m "Saved .changes/%s-daily.json" repo_name);
11411141+ Ok ()
11421142+ else Ok ()
11431143+ in
11441144+ match save_result with
11451145+ | Error e -> Error e
11461146+ | Ok () ->
11471147+ all_changes_files := updated_cf :: !all_changes_files;
11481148+ process_repos rest
11491149+ in
11501150+ match process_repos repos with
11511151+ | Error e -> Error e
11521152+ | Ok () ->
11531153+ (* Generate aggregated DAILY-CHANGES.md *)
11541154+ if not dry_run && !all_changes_files <> [] then begin
11551155+ let raw_markdown = Changes.aggregate_daily ~history !all_changes_files in
11561156+ (* Refine the markdown through Claude for better narrative *)
11571157+ Log.info (fun m -> m "Refining daily changelog with Claude...");
11581158+ let markdown = Eio.Switch.run @@ fun sw ->
11591159+ match Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock raw_markdown with
11601160+ | Ok refined ->
11611161+ Log.app (fun m -> m "Refined daily changelog for readability");
11621162+ refined
11631163+ | Error e ->
11641164+ Log.warn (fun m -> m "Failed to refine changelog: %s (using raw version)" e);
11651165+ raw_markdown
11661166+ in
11671167+ let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md") in
11681168+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
11691169+ Log.app (fun m -> m "Generated DAILY-CHANGES.md at monorepo root")
11701170+ end;
11711171+ Ok ()
11721172+ end
+34-2
monopam/lib/monopam.mli
···183183 generates weekly changelog entries using Claude AI.
184184185185 For each repository (or the specified package's repository):
186186- 1. Loads or creates CHANGES.json
186186+ 1. Loads or creates .changes/<repo>.json
187187 2. For each week that doesn't have an entry, retrieves git commits
188188 3. Sends commits to Claude for analysis
189189- 4. Saves changelog entries back to CHANGES.json
189189+ 4. Saves changelog entries back to .changes/<repo>.json
190190191191 Also generates an aggregated CHANGES.md at the monorepo root.
192192···198198 @param weeks Number of past weeks to analyze (default: 1)
199199 @param history Number of recent weeks to include in CHANGES.md (default: 12)
200200 @param dry_run If true, preview changes without writing files *)
201201+202202+val changes_daily :
203203+ proc:_ Eio.Process.mgr ->
204204+ fs:Eio.Fs.dir_ty Eio.Path.t ->
205205+ config:Config.t ->
206206+ clock:float Eio.Time.clock_ty Eio.Resource.t ->
207207+ ?package:string ->
208208+ ?days:int ->
209209+ ?history:int ->
210210+ ?dry_run:bool ->
211211+ unit ->
212212+ (unit, error) result
213213+(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ()]
214214+ generates daily changelog entries using Claude AI.
215215+216216+ For each repository (or the specified package's repository):
217217+ 1. Loads or creates .changes/<repo>-daily.json
218218+ 2. For each day that doesn't have an entry, retrieves git commits
219219+ 3. Sends commits to Claude for analysis
220220+ 4. Saves changelog entries back to .changes/<repo>-daily.json
221221+222222+ Also generates an aggregated DAILY-CHANGES.md at the monorepo root.
223223+ Repositories with no user-facing changes will have blank entries.
224224+225225+ @param proc Eio process manager
226226+ @param fs Eio filesystem
227227+ @param config Monopam configuration
228228+ @param clock Eio clock for time operations
229229+ @param package Optional specific repository to process
230230+ @param days Number of past days to analyze (default: 1)
231231+ @param history Number of recent days to include in DAILY-CHANGES.md (default: 30)
232232+ @param dry_run If true, preview changes without writing files *)