forked from
anil.recoil.org/monopam
Monorepo management for opam overlays
1(** Changelog generation for monopam.
2
3 This module handles generating weekly and daily changelog entries using
4 Claude AI to analyze git commit history and produce user-facing change
5 summaries.
6
7 Changes are stored in a .changes directory at the monorepo root:
8 - .changes/<repo_name>.json - weekly changelog entries
9 - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file
10 per day per repo)
11 - .changes/YYYYMMDD.json - aggregated daily changes for broadcasting
12
13 {1 Submodules}
14
15 - {!Aggregated} - Types and I/O for aggregated daily changes (YYYYMMDD.json)
16 - {!Daily} - Types and I/O for per-day-per-repo changes
17 (repo-YYYY-MM-DD.json)
18 - {!Query} - High-level query interface for changes *)
19
20module Aggregated = Changes_aggregated
21(** Re-export submodules for querying changes *)
22
23module Daily = Changes_daily
24module Query = Changes_query
25
26type commit_range = { from_hash : string; to_hash : string; count : int }
27
28type weekly_entry = {
29 week_start : string; (* ISO date YYYY-MM-DD, Monday *)
30 week_end : string; (* ISO date YYYY-MM-DD, Sunday *)
31 summary : string; (* One-line summary *)
32 changes : string list; (* Bullet points *)
33 commit_range : commit_range;
34}
35
36type daily_entry = {
37 date : string; (* ISO date YYYY-MM-DD *)
38 hour : int; (* Hour of day 0-23 *)
39 timestamp : Ptime.t; (* RFC3339 timestamp for precise ordering *)
40 summary : string; (* One-line summary *)
41 changes : string list; (* Bullet points *)
42 commit_range : commit_range;
43 contributors : string list; (* List of contributors for this entry *)
44 repo_url : string option; (* Upstream repository URL *)
45}
46
47type changes_file = { repository : string; entries : weekly_entry list }
48type daily_changes_file = { repository : string; entries : daily_entry list }
49
50(** Mode for changelog generation *)
51type mode = Weekly | Daily
52
53(* Jsont codecs *)
54
55let commit_range_jsont =
56 let make from_hash to_hash count = { from_hash; to_hash; count } in
57 Jsont.Object.map ~kind:"commit_range" make
58 |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash)
59 |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash)
60 |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count)
61 |> Jsont.Object.finish
62
63let weekly_entry_jsont : weekly_entry Jsont.t =
64 let make week_start week_end summary changes commit_range : weekly_entry =
65 { week_start; week_end; summary; changes; commit_range }
66 in
67 Jsont.Object.map ~kind:"weekly_entry" make
68 |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun (e : weekly_entry) ->
69 e.week_start)
70 |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun (e : weekly_entry) ->
71 e.week_end)
72 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : weekly_entry) ->
73 e.summary)
74 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string)
75 ~enc:(fun (e : weekly_entry) -> e.changes)
76 |> Jsont.Object.mem "commit_range" commit_range_jsont
77 ~enc:(fun (e : weekly_entry) -> e.commit_range)
78 |> Jsont.Object.finish
79
80let changes_file_jsont : changes_file Jsont.t =
81 let make repository entries : changes_file = { repository; entries } in
82 Jsont.Object.map ~kind:"changes_file" make
83 |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : changes_file) ->
84 f.repository)
85 |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont)
86 ~enc:(fun (f : changes_file) -> f.entries)
87 |> Jsont.Object.finish
88
89let ptime_jsont =
90 let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in
91 let dec s =
92 match Ptime.of_rfc3339 s with
93 | Ok (t, _, _) -> t
94 | Error _ -> failwith ("Invalid timestamp: " ^ s)
95 in
96 Jsont.map ~dec ~enc Jsont.string
97
98let daily_entry_jsont : daily_entry Jsont.t =
99 let make date hour timestamp summary changes commit_range contributors
100 repo_url : daily_entry =
101 {
102 date;
103 hour;
104 timestamp;
105 summary;
106 changes;
107 commit_range;
108 contributors;
109 repo_url;
110 }
111 in
112 (* Default hour and timestamp for backwards compat when reading old files *)
113 let default_hour = 0 in
114 let default_timestamp = Ptime.epoch in
115 Jsont.Object.map ~kind:"daily_entry" make
116 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun (e : daily_entry) -> e.date)
117 |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour
118 ~enc:(fun (e : daily_entry) -> e.hour)
119 |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp
120 ~enc:(fun (e : daily_entry) -> e.timestamp)
121 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) ->
122 e.summary)
123 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string)
124 ~enc:(fun (e : daily_entry) -> e.changes)
125 |> Jsont.Object.mem "commit_range" commit_range_jsont
126 ~enc:(fun (e : daily_entry) -> e.commit_range)
127 |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[]
128 ~enc:(fun (e : daily_entry) -> e.contributors)
129 |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None
130 ~enc:(fun (e : daily_entry) -> e.repo_url)
131 |> Jsont.Object.finish
132
133let daily_changes_file_jsont : daily_changes_file Jsont.t =
134 let make repository entries : daily_changes_file = { repository; entries } in
135 Jsont.Object.map ~kind:"daily_changes_file" make
136 |> Jsont.Object.mem "repository" Jsont.string
137 ~enc:(fun (f : daily_changes_file) -> f.repository)
138 |> Jsont.Object.mem "entries" (Jsont.list daily_entry_jsont)
139 ~enc:(fun (f : daily_changes_file) -> f.entries)
140 |> Jsont.Object.finish
141
142(* File I/O *)
143
144(* Helper to ensure .changes directory exists *)
145let ensure_changes_dir ~fs monorepo =
146 let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
147 match Eio.Path.kind ~follow:true changes_dir with
148 | `Directory -> ()
149 | _ -> Eio.Path.mkdir ~perm:0o755 changes_dir
150 | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 changes_dir
151
152(* Load weekly changes from .changes/<repo>.json in monorepo *)
153let load ~fs ~monorepo repo_name =
154 let file_path =
155 Eio.Path.(
156 fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ ".json"))
157 in
158 match Eio.Path.kind ~follow:true file_path with
159 | `Regular_file -> (
160 let content = Eio.Path.load file_path in
161 match Jsont_bytesrw.decode_string changes_file_jsont content with
162 | Ok cf -> Ok cf
163 | Error e ->
164 Error (Format.sprintf "Failed to parse %s.json: %s" repo_name e))
165 | _ -> Ok { repository = repo_name; entries = [] }
166 | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
167
168(* Save weekly changes to .changes/<repo>.json in monorepo *)
169let save ~fs ~monorepo (cf : changes_file) =
170 ensure_changes_dir ~fs monorepo;
171 let file_path =
172 Eio.Path.(
173 fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ ".json"))
174 in
175 match
176 Jsont_bytesrw.encode_string ~format:Jsont.Indent changes_file_jsont cf
177 with
178 | Ok content ->
179 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
180 Ok ()
181 | Error e ->
182 Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e)
183
184(* Filename for daily changes: <repo>-<YYYY-MM-DD>.json *)
185let daily_filename repo_name date = repo_name ^ "-" ^ date ^ ".json"
186
187(* Check if daily file exists on disk *)
188let daily_exists ~fs ~monorepo ~date repo_name =
189 let filename = daily_filename repo_name date in
190 let file_path =
191 Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
192 in
193 match Eio.Path.kind ~follow:true file_path with
194 | `Regular_file -> true
195 | _ -> false
196 | exception Eio.Io _ -> false
197
198(* Load daily changes from .changes/<repo>-<date>.json in monorepo *)
199let load_daily ~fs ~monorepo ~date repo_name =
200 let filename = daily_filename repo_name date in
201 let file_path =
202 Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
203 in
204 match Eio.Path.kind ~follow:true file_path with
205 | `Regular_file -> (
206 let content = Eio.Path.load file_path in
207 match Jsont_bytesrw.decode_string daily_changes_file_jsont content with
208 | Ok cf -> Ok cf
209 | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e))
210 | _ -> Ok { repository = repo_name; entries = [] }
211 | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
212
213(* Save daily changes to .changes/<repo>-<date>.json in monorepo *)
214let save_daily ~fs ~monorepo ~date (cf : daily_changes_file) =
215 ensure_changes_dir ~fs monorepo;
216 let filename = daily_filename cf.repository date in
217 let file_path =
218 Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
219 in
220 match
221 Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf
222 with
223 | Ok content ->
224 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
225 Ok ()
226 | Error e -> Error (Format.sprintf "Failed to encode %s: %s" filename e)
227
228(* Markdown generation *)
229
230let to_markdown (cf : changes_file) =
231 let buf = Buffer.create 1024 in
232 Buffer.add_string buf (Printf.sprintf "# %s Changelog\n\n" cf.repository);
233 List.iter
234 (fun (entry : weekly_entry) ->
235 Buffer.add_string buf
236 (Printf.sprintf "## Week of %s to %s\n\n" entry.week_start
237 entry.week_end);
238 Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
239 List.iter
240 (fun change -> Buffer.add_string buf (Printf.sprintf "- %s\n" change))
241 entry.changes;
242 Buffer.add_string buf "\n")
243 cf.entries;
244 Buffer.contents buf
245
246let aggregate ~history (cfs : changes_file list) =
247 (* Collect all entries from all files, tagged with repository *)
248 let all_entries =
249 List.concat_map
250 (fun (cf : changes_file) ->
251 List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
252 cfs
253 in
254 (* Sort by week_start descending *)
255 let sorted =
256 List.sort
257 (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
258 String.compare e2.week_start e1.week_start)
259 all_entries
260 in
261 (* Group by week *)
262 let rec group_by_week acc current_week current_group = function
263 | [] ->
264 if current_group <> [] then
265 (current_week, List.rev current_group) :: acc
266 else acc
267 | (repo, (entry : weekly_entry)) :: rest ->
268 let week_key = entry.week_start ^ " to " ^ entry.week_end in
269 if current_week = "" || current_week = week_key then
270 group_by_week acc week_key ((repo, entry) :: current_group) rest
271 else
272 group_by_week
273 ((current_week, List.rev current_group) :: acc)
274 week_key
275 [ (repo, entry) ]
276 rest
277 in
278 let grouped = List.rev (group_by_week [] "" [] sorted) in
279 (* Take only the requested number of weeks *)
280 let limited =
281 if history > 0 then List.filteri (fun i _ -> i < history) grouped
282 else grouped
283 in
284 (* Generate markdown *)
285 let buf = Buffer.create 4096 in
286 Buffer.add_string buf "# Changelog\n\n";
287 List.iter
288 (fun (week_key, entries) ->
289 Buffer.add_string buf (Printf.sprintf "## Week of %s\n\n" week_key);
290 List.iter
291 (fun (repo, (entry : weekly_entry)) ->
292 Buffer.add_string buf (Printf.sprintf "### %s\n" repo);
293 Buffer.add_string buf (Printf.sprintf "%s\n" entry.summary);
294 List.iter
295 (fun change ->
296 Buffer.add_string buf (Printf.sprintf "- %s\n" change))
297 entry.changes;
298 Buffer.add_string buf "\n")
299 entries)
300 limited;
301 Buffer.contents buf
302
303(* Week calculation *)
304
305(* Get day of week: 0 = Sunday, 1 = Monday, ... 6 = Saturday
306 Using Zeller's congruence for Gregorian calendar *)
307let day_of_week year month day =
308 let y = if month < 3 then year - 1 else year in
309 let m = if month < 3 then month + 12 else month in
310 let q = day in
311 let k = y mod 100 in
312 let j = y / 100 in
313 let h = (q + (13 * (m + 1) / 5) + k + (k / 4) + (j / 4) - (2 * j)) mod 7 in
314 (* Convert from Zeller's (0=Sat) to standard (0=Sun) *)
315 (h + 6) mod 7
316
317let add_days (y, m, d) n =
318 (* Simple day addition - handles month/year boundaries *)
319 let days_in_month year month =
320 match month with
321 | 1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31
322 | 4 | 6 | 9 | 11 -> 30
323 | 2 ->
324 if (year mod 4 = 0 && year mod 100 <> 0) || year mod 400 = 0 then 29
325 else 28
326 | _ -> 30
327 in
328 let rec loop y m d n =
329 if n = 0 then (y, m, d)
330 else if n > 0 then
331 let dim = days_in_month y m in
332 if d + n <= dim then (y, m, d + n)
333 else
334 let remaining = dim - d in
335 let new_m = if m = 12 then 1 else m + 1 in
336 let new_y = if m = 12 then y + 1 else y in
337 loop new_y new_m 1 (n - remaining - 1)
338 else if
339 (* n < 0 *)
340 d + n >= 1
341 then (y, m, d + n)
342 else
343 let new_m = if m = 1 then 12 else m - 1 in
344 let new_y = if m = 1 then y - 1 else y in
345 let dim = days_in_month new_y new_m in
346 loop new_y new_m dim (n + d)
347 in
348 loop y m d n
349
350let format_date (y, m, d) = Printf.sprintf "%04d-%02d-%02d" y m d
351
352let week_of_date (y, m, d) =
353 let dow = day_of_week y m d in
354 (* Monday = 1, so days to subtract to get to Monday *)
355 let days_to_monday = if dow = 0 then 6 else dow - 1 in
356 let monday = add_days (y, m, d) (-days_to_monday) in
357 let sunday = add_days monday 6 in
358 (format_date monday, format_date sunday)
359
360let week_of_ptime t =
361 let (y, m, d), _ = Ptime.to_date_time t in
362 week_of_date (y, m, d)
363
364let has_week (cf : changes_file) ~week_start =
365 List.exists (fun (e : weekly_entry) -> e.week_start = week_start) cf.entries
366
367let date_of_ptime t =
368 let (y, m, d), _ = Ptime.to_date_time t in
369 format_date (y, m, d)
370
371let has_day (cf : daily_changes_file) ~date:_ =
372 (* With per-day files, the file is already for a specific date.
373 This function now checks if the file has any entries. *)
374 cf.entries <> []
375
376(* Aggregate daily changes into DAILY-CHANGES.md *)
377let aggregate_daily ~history (cfs : daily_changes_file list) =
378 (* Collect all entries from all files, tagged with repository *)
379 let all_entries =
380 List.concat_map
381 (fun (cf : daily_changes_file) ->
382 List.map (fun (e : daily_entry) -> (cf.repository, e)) cf.entries)
383 cfs
384 in
385 (* Sort by date descending *)
386 let sorted =
387 List.sort
388 (fun (_, (e1 : daily_entry)) (_, (e2 : daily_entry)) ->
389 String.compare e2.date e1.date)
390 all_entries
391 in
392 (* Group by date *)
393 let rec group_by_date acc current_date current_group = function
394 | [] ->
395 if current_group <> [] then
396 (current_date, List.rev current_group) :: acc
397 else acc
398 | (repo, (entry : daily_entry)) :: rest ->
399 if current_date = "" || current_date = entry.date then
400 group_by_date acc entry.date ((repo, entry) :: current_group) rest
401 else
402 group_by_date
403 ((current_date, List.rev current_group) :: acc)
404 entry.date
405 [ (repo, entry) ]
406 rest
407 in
408 let grouped = List.rev (group_by_date [] "" [] sorted) in
409 (* Take only the requested number of days *)
410 let limited =
411 if history > 0 then List.filteri (fun i _ -> i < history) grouped
412 else grouped
413 in
414 (* Generate markdown - only include repos with actual changes *)
415 let buf = Buffer.create 4096 in
416 Buffer.add_string buf "# Daily Changelog\n\n";
417 List.iter
418 (fun (date, entries) ->
419 (* Filter out entries with empty changes - these are repos with no changes *)
420 let entries_with_changes =
421 List.filter
422 (fun (_, (entry : daily_entry)) -> entry.changes <> [])
423 entries
424 in
425 if entries_with_changes <> [] then begin
426 Buffer.add_string buf (Printf.sprintf "## %s\n\n" date);
427 List.iter
428 (fun (repo, (entry : daily_entry)) ->
429 (* Format repo name with link if URL available *)
430 let repo_header =
431 match entry.repo_url with
432 | Some url -> Printf.sprintf "[%s](%s)" repo url
433 | None -> repo
434 in
435 Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_header);
436 Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
437 List.iter
438 (fun change ->
439 Buffer.add_string buf (Printf.sprintf "- %s\n" change))
440 entry.changes;
441 (* Add contributors if any *)
442 if entry.contributors <> [] then begin
443 let contributors_str = String.concat ", " entry.contributors in
444 Buffer.add_string buf
445 (Printf.sprintf "\n*Contributors: %s*\n" contributors_str)
446 end;
447 Buffer.add_string buf "\n")
448 entries_with_changes
449 end)
450 limited;
451 Buffer.contents buf
452
453(* Claude prompt generation *)
454
455let generate_weekly_prompt ~repository ~week_start ~week_end commits =
456 let buf = Buffer.create 4096 in
457 Buffer.add_string buf
458 (Printf.sprintf
459 "You are analyzing git commits for the OCaml library \"%s\".\n"
460 repository);
461 Buffer.add_string buf
462 (Printf.sprintf
463 "Generate a user-facing changelog entry for the week of %s to %s.\n\n"
464 week_start week_end);
465 Buffer.add_string buf "## Commits this week:\n\n";
466 List.iter
467 (fun (commit : Git.log_entry) ->
468 Buffer.add_string buf
469 (Printf.sprintf "### %s by %s (%s)\n"
470 (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
471 commit.author commit.date);
472 Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
473 if commit.body <> "" then begin
474 Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
475 end;
476 Buffer.add_string buf "---\n\n")
477 commits;
478 Buffer.add_string buf
479 {|## Instructions:
480
4811. Focus on USER-FACING changes only. Skip:
482 - Internal refactoring with no API impact
483 - CI/build system tweaks
484 - Typo fixes in code comments
485 - Dependency bumps (unless they add features)
486
4872. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty
488 summary and empty changes array. Do NOT write "no changes" or similar text.
489 Example for no changes: {"summary": "", "changes": []}
490
4913. Otherwise, respond in this exact JSON format:
492{
493 "summary": "One sentence describing the most important change",
494 "changes": [
495 "First user-facing change as a bullet point",
496 "Second change",
497 "..."
498 ]
499}
500
5014. Write for developers using this library. Be:
502 - Concise (max 80 chars per bullet)
503 - Specific (mention function/module names)
504 - Action-oriented (start with verbs: Added, Fixed, Improved, Removed)
505
5065. Maximum 5 bullet points. Group related changes if needed.
507|};
508 Buffer.contents buf
509
510let generate_daily_prompt ~repository ~date commits =
511 let buf = Buffer.create 4096 in
512 Buffer.add_string buf
513 (Printf.sprintf
514 "You are analyzing git commits for the OCaml library \"%s\".\n"
515 repository);
516 Buffer.add_string buf
517 (Printf.sprintf "Generate a user-facing changelog entry for %s.\n\n" date);
518 Buffer.add_string buf "## Commits today:\n\n";
519 List.iter
520 (fun (commit : Git.log_entry) ->
521 Buffer.add_string buf
522 (Printf.sprintf "### %s by %s (%s)\n"
523 (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
524 commit.author commit.date);
525 Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
526 if commit.body <> "" then begin
527 Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
528 end;
529 Buffer.add_string buf "---\n\n")
530 commits;
531 Buffer.add_string buf
532 {|## Instructions:
533
5341. Focus on USER-FACING changes only. Skip:
535 - Internal refactoring with no API impact
536 - CI/build system tweaks
537 - Typo fixes in code comments
538 - Dependency bumps (unless they add features)
539
5402. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty
541 summary and empty changes array. Do NOT write "no changes" or similar text.
542 Example for no changes: {"summary": "", "changes": []}
543
5443. Otherwise, respond in this exact JSON format:
545{
546 "summary": "One sentence describing the most important change",
547 "changes": [
548 "First user-facing change as a bullet point",
549 "Second change",
550 "..."
551 ]
552}
553
5544. Write for developers using this library. Be:
555 - Concise (max 80 chars per bullet)
556 - Specific (mention function/module names)
557 - Action-oriented (start with verbs: Added, Fixed, Improved, Removed)
558
5595. Maximum 5 bullet points. Group related changes if needed.
560|};
561 Buffer.contents buf
562
563(* Backwards compatibility *)
564let generate_prompt = generate_weekly_prompt
565
566(* Response parsing *)
567
568type claude_response = { summary : string; changes : string list }
569
570let claude_response_jsont =
571 let make summary changes = { summary; changes } in
572 Jsont.Object.map ~kind:"claude_response" make
573 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun r -> r.summary)
574 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun r ->
575 r.changes)
576 |> Jsont.Object.finish
577
578let parse_claude_response text =
579 let text = String.trim text in
580 (* Legacy support for NO_CHANGES response *)
581 if text = "NO_CHANGES" then Ok None
582 else
583 match Jsont_bytesrw.decode_string claude_response_jsont text with
584 | Ok r ->
585 (* Treat empty summary and changes as no changes *)
586 if r.summary = "" && r.changes = [] then Ok None else Ok (Some r)
587 | Error e -> Error (Format.sprintf "Failed to parse Claude response: %s" e)
588
589(* Main analysis function *)
590
591let analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end
592 commits =
593 if commits = [] then Ok None
594 else begin
595 let prompt = generate_prompt ~repository ~week_start ~week_end commits in
596
597 (* Create Claude options with structured output *)
598 let output_schema =
599 let open Jsont in
600 Object
601 ( [
602 (("type", Meta.none), String ("object", Meta.none));
603 ( ("properties", Meta.none),
604 Object
605 ( [
606 ( ("summary", Meta.none),
607 Object
608 ( [ (("type", Meta.none), String ("string", Meta.none)) ],
609 Meta.none ) );
610 ( ("changes", Meta.none),
611 Object
612 ( [
613 (("type", Meta.none), String ("array", Meta.none));
614 ( ("items", Meta.none),
615 Object
616 ( [
617 ( ("type", Meta.none),
618 String ("string", Meta.none) );
619 ],
620 Meta.none ) );
621 ],
622 Meta.none ) );
623 ],
624 Meta.none ) );
625 ( ("required", Meta.none),
626 Array
627 ( [
628 String ("summary", Meta.none); String ("changes", Meta.none);
629 ],
630 Meta.none ) );
631 ],
632 Meta.none )
633 in
634 let output_format =
635 Claude.Proto.Structured_output.of_json_schema output_schema
636 in
637 let options =
638 Claude.Options.default
639 |> Claude.Options.with_output_format output_format
640 |> Claude.Options.with_max_turns 1
641 in
642
643 let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
644 Claude.Client.query client prompt;
645
646 let responses = Claude.Client.receive_all client in
647 let result = ref None in
648 List.iter
649 (function
650 | Claude.Response.Complete c -> (
651 match Claude.Response.Complete.structured_output c with
652 | Some json -> (
653 match Jsont.Json.decode claude_response_jsont json with
654 | Ok r -> result := Some (Ok (Some r))
655 | Error e ->
656 result :=
657 Some
658 (Error
659 (Format.sprintf "Failed to decode response: %s" e)))
660 | None -> (
661 (* Try to get text and parse it as fallback *)
662 match Claude.Response.Complete.result_text c with
663 | Some text -> result := Some (parse_claude_response text)
664 | None -> result := Some (Ok None)))
665 | Claude.Response.Text t ->
666 let text = Claude.Response.Text.content t in
667 if String.trim text = "NO_CHANGES" then result := Some (Ok None)
668 | Claude.Response.Error e ->
669 result :=
670 Some
671 (Error
672 (Printf.sprintf "Claude error: %s"
673 (Claude.Response.Error.message e)))
674 | _ -> ())
675 responses;
676
677 match !result with Some r -> r | None -> Ok None
678 end
679
680(* Daily analysis function *)
681let analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits =
682 if commits = [] then Ok None
683 else begin
684 let prompt = generate_daily_prompt ~repository ~date commits in
685
686 (* Create Claude options with structured output *)
687 let output_schema =
688 let open Jsont in
689 Object
690 ( [
691 (("type", Meta.none), String ("object", Meta.none));
692 ( ("properties", Meta.none),
693 Object
694 ( [
695 ( ("summary", Meta.none),
696 Object
697 ( [ (("type", Meta.none), String ("string", Meta.none)) ],
698 Meta.none ) );
699 ( ("changes", Meta.none),
700 Object
701 ( [
702 (("type", Meta.none), String ("array", Meta.none));
703 ( ("items", Meta.none),
704 Object
705 ( [
706 ( ("type", Meta.none),
707 String ("string", Meta.none) );
708 ],
709 Meta.none ) );
710 ],
711 Meta.none ) );
712 ],
713 Meta.none ) );
714 ( ("required", Meta.none),
715 Array
716 ( [
717 String ("summary", Meta.none); String ("changes", Meta.none);
718 ],
719 Meta.none ) );
720 ],
721 Meta.none )
722 in
723 let output_format =
724 Claude.Proto.Structured_output.of_json_schema output_schema
725 in
726 let options =
727 Claude.Options.default
728 |> Claude.Options.with_output_format output_format
729 |> Claude.Options.with_max_turns 1
730 in
731
732 let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
733 Claude.Client.query client prompt;
734
735 let responses = Claude.Client.receive_all client in
736 let result = ref None in
737 List.iter
738 (function
739 | Claude.Response.Complete c -> (
740 match Claude.Response.Complete.structured_output c with
741 | Some json -> (
742 match Jsont.Json.decode claude_response_jsont json with
743 | Ok r ->
744 (* Treat empty response as no changes *)
745 if r.summary = "" && r.changes = [] then
746 result := Some (Ok None)
747 else result := Some (Ok (Some r))
748 | Error e ->
749 result :=
750 Some
751 (Error
752 (Format.sprintf "Failed to decode response: %s" e)))
753 | None -> (
754 (* Try to get text and parse it as fallback *)
755 match Claude.Response.Complete.result_text c with
756 | Some text -> result := Some (parse_claude_response text)
757 | None -> result := Some (Ok None)))
758 | Claude.Response.Text t ->
759 let text = Claude.Response.Text.content t in
760 if String.trim text = "NO_CHANGES" then result := Some (Ok None)
761 | Claude.Response.Error e ->
762 result :=
763 Some
764 (Error
765 (Printf.sprintf "Claude error: %s"
766 (Claude.Response.Error.message e)))
767 | _ -> ())
768 responses;
769
770 match !result with Some r -> r | None -> Ok None
771 end
772
773(* Refine daily changelog markdown to be more narrative *)
774let refine_daily_changelog ~sw ~process_mgr ~clock markdown =
775 let prompt =
776 Printf.sprintf
777 {|You are editing a daily changelog for an OCaml monorepo.
778
779Your task is to refine the following changelog to be:
7801. More narrative and human-readable - write it as a daily update that developers will want to read
7812. Grouped by related changes - if multiple repos have related changes, group them together
7823. Succinct but complete - don't lose any information, but make it more concise
7834. Well-ordered - put the most significant changes first
784
785Keep the markdown format with:
786- A main heading for each date
787- 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"
788- Bullet points for individual changes
789- Preserve all contributor attributions (format: — *Contributor Name*)
790- 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)
791- Format each bullet as: **[repo-name](url)**: Description — *Contributors* (if any)
792
793IMPORTANT: For "initial import" or "added as subtree" entries:
794- Put these in a dedicated "New Libraries" section
795- Expand the description to explain what the library does and its purpose
796- 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
797- 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"
798
799Here is the changelog to refine:
800
801%s
802
803Output ONLY the refined markdown, no explanation or preamble.|}
804 markdown
805 in
806
807 let options = Claude.Options.default |> Claude.Options.with_max_turns 1 in
808
809 let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
810 Claude.Client.query client prompt;
811
812 let responses = Claude.Client.receive_all client in
813 let result = ref None in
814 List.iter
815 (function
816 | Claude.Response.Complete c -> (
817 match Claude.Response.Complete.result_text c with
818 | Some text -> result := Some (Ok text)
819 | None -> result := Some (Ok markdown) (* fallback to original *))
820 | Claude.Response.Error e ->
821 result :=
822 Some
823 (Error
824 (Printf.sprintf "Claude error: %s"
825 (Claude.Response.Error.message e)))
826 | _ -> ())
827 responses;
828
829 match !result with
830 | Some r -> r
831 | None -> Ok markdown (* fallback to original *)
832
833(* Simple string containment check *)
834let string_contains_s haystack needle =
835 let hlen = String.length haystack in
836 let nlen = String.length needle in
837 if nlen > hlen then false
838 else begin
839 let rec check i =
840 if i > hlen - nlen then false
841 else if String.sub haystack i nlen = needle then true
842 else check (i + 1)
843 in
844 check 0
845 end
846
847(* Infer change type from summary text *)
848let infer_change_type summary =
849 let summary_lower = String.lowercase_ascii summary in
850 if
851 String.starts_with ~prefix:"initial import" summary_lower
852 || String.starts_with ~prefix:"added as subtree" summary_lower
853 || String.starts_with ~prefix:"added" summary_lower
854 && String.ends_with ~suffix:"library" summary_lower
855 then Changes_aggregated.New_library
856 else if
857 List.exists
858 (fun kw -> string_contains_s summary_lower kw)
859 [ "fix"; "bugfix"; "bug fix"; "repair"; "patch"; "resolve"; "correct" ]
860 then Changes_aggregated.Bugfix
861 else if
862 List.exists
863 (fun kw -> string_contains_s summary_lower kw)
864 [
865 "refactor";
866 "cleanup";
867 "clean up";
868 "reorganize";
869 "restructure";
870 "simplify";
871 ]
872 then Changes_aggregated.Refactor
873 else if
874 List.exists
875 (fun kw -> string_contains_s summary_lower kw)
876 [ "doc"; "documentation"; "readme"; "comment"; "tutorial"; "guide" ]
877 then Changes_aggregated.Documentation
878 else if
879 List.exists
880 (fun kw -> string_contains_s summary_lower kw)
881 [ "add"; "new"; "feature"; "implement"; "support"; "introduce"; "enable" ]
882 then Changes_aggregated.Feature
883 else Changes_aggregated.Unknown
884
885(** Generate an aggregated daily file from individual daily json files. This
886 creates a YYYYMMDD.json file in the .changes directory. *)
887let generate_aggregated ~fs ~monorepo ~date ~git_head ~now =
888 let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
889
890 (* List all *-<date>.json files (new per-day format) *)
891 let files = try Eio.Path.read_dir changes_dir with Eio.Io _ -> [] in
892 (* Match files like "<repo>-2026-01-19.json" for the given date *)
893 let date_suffix = "-" ^ date ^ ".json" in
894 let date_suffix_len = String.length date_suffix in
895 let daily_files =
896 List.filter
897 (fun f ->
898 String.ends_with ~suffix:date_suffix f
899 && String.length f > date_suffix_len)
900 files
901 in
902
903 (* Load all daily files for this date and collect entries *)
904 let entries =
905 List.concat_map
906 (fun filename ->
907 (* Extract repo name: filename is "<repo>-<date>.json" *)
908 let repo_name =
909 String.sub filename 0 (String.length filename - date_suffix_len)
910 in
911 let path = Eio.Path.(changes_dir / filename) in
912 try
913 let content = Eio.Path.load path in
914 match
915 Jsont_bytesrw.decode_string daily_changes_file_jsont content
916 with
917 | Ok dcf ->
918 List.filter_map
919 (fun (e : daily_entry) ->
920 if e.changes <> [] then Some (repo_name, e) else None)
921 dcf.entries
922 | Error _ -> []
923 with Eio.Io _ -> [])
924 daily_files
925 in
926
927 (* Convert to aggregated format *)
928 let agg_entries =
929 List.map
930 (fun (repo_name, (e : daily_entry)) ->
931 let change_type = infer_change_type e.summary in
932 Changes_aggregated.
933 {
934 repository = repo_name;
935 hour = e.hour;
936 timestamp = e.timestamp;
937 summary = e.summary;
938 changes = e.changes;
939 commit_range =
940 {
941 from_hash = e.commit_range.from_hash;
942 to_hash = e.commit_range.to_hash;
943 count = e.commit_range.count;
944 };
945 contributors = e.contributors;
946 repo_url = e.repo_url;
947 change_type;
948 })
949 entries
950 in
951
952 (* Collect all unique authors *)
953 let authors =
954 entries
955 |> List.concat_map (fun (_, (e : daily_entry)) -> e.contributors)
956 |> List.sort_uniq String.compare
957 in
958
959 (* Create the aggregated structure *)
960 let aggregated : Changes_aggregated.t =
961 { date; generated_at = now; git_head; entries = agg_entries; authors }
962 in
963
964 (* Save to YYYYMMDD.json *)
965 let changes_dir_fpath = Fpath.(v (Fpath.to_string monorepo) / ".changes") in
966 Changes_aggregated.save ~fs ~changes_dir:changes_dir_fpath aggregated