(*--------------------------------------------------------------------------- Copyright (c) 2026 Anil Madhavapeddy . All rights reserved. SPDX-License-Identifier: ISC ---------------------------------------------------------------------------*) (** Aggregated daily changes format. This module provides types and JSON codecs for the aggregated daily changes format stored in [.changes/YYYYMMDD.json] files. These files combine all repository changes for a single day into a structured format suitable for broadcasting. *) type change_type = | Feature | Bugfix | Documentation | Refactor | New_library | Unknown let change_type_of_string = function | "feature" -> Feature | "bugfix" -> Bugfix | "documentation" -> Documentation | "refactor" -> Refactor | "new_library" -> New_library | _ -> Unknown let string_of_change_type = function | Feature -> "feature" | Bugfix -> "bugfix" | Documentation -> "documentation" | Refactor -> "refactor" | New_library -> "new_library" | Unknown -> "unknown" type commit_range = { from_hash : string; to_hash : string; count : int } type entry = { repository : string; hour : int; timestamp : Ptime.t; summary : string; changes : string list; commit_range : commit_range; contributors : string list; repo_url : string option; change_type : change_type; } type t = { date : string; generated_at : Ptime.t; git_head : string; entries : entry list; authors : string list; } (* JSON codecs *) let change_type_jsont = Jsont.enum ~kind:"change_type" [ ("feature", Feature); ("bugfix", Bugfix); ("documentation", Documentation); ("refactor", Refactor); ("new_library", New_library); ("unknown", Unknown); ] 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 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 entry_jsont = let make repository hour timestamp summary changes commit_range contributors repo_url change_type = { repository; hour; timestamp; summary; changes; commit_range; contributors; repo_url; change_type; } 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:"aggregated_entry" make |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun e -> e.repository) |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour) |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp) |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary) |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes) |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range) |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors) |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url) |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown ~enc:(fun e -> e.change_type) |> Jsont.Object.finish let jsont = let make date generated_at git_head entries authors = { date; generated_at; git_head; entries; authors } in Jsont.Object.map ~kind:"aggregated_changes" make |> Jsont.Object.mem "date" Jsont.string ~enc:(fun t -> t.date) |> Jsont.Object.mem "generated_at" ptime_jsont ~enc:(fun t -> t.generated_at) |> Jsont.Object.mem "git_head" Jsont.string ~enc:(fun t -> t.git_head) |> Jsont.Object.mem "entries" (Jsont.list entry_jsont) ~enc:(fun t -> t.entries) |> Jsont.Object.mem "authors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun t -> t.authors) |> Jsont.Object.finish (* File I/O *) let filename_of_date date = (* date is in YYYY-MM-DD format, convert to YYYYMMDD.json *) let clean = String.concat "" (String.split_on_char '-' date) in clean ^ ".json" let date_of_filename filename = (* YYYYMMDD.json -> YYYY-MM-DD *) if String.length filename >= 12 && String.sub filename 8 5 = ".json" then let yyyymmdd = String.sub filename 0 8 in let yyyy = String.sub yyyymmdd 0 4 in let mm = String.sub yyyymmdd 4 2 in let dd = String.sub yyyymmdd 6 2 in Some (yyyy ^ "-" ^ mm ^ "-" ^ dd) else None let load ~fs ~changes_dir ~date = let filename = filename_of_date date in let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / 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 jsont content with | Ok t -> Ok t | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e)) | _ -> Error (Format.sprintf "File not found: %s" filename) | exception Eio.Io _ -> Error (Format.sprintf "Could not read %s" filename) let load_range ~fs ~changes_dir ~from_date ~to_date = (* List all YYYYMMDD.json files and filter by range *) let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in match Eio.Path.kind ~follow:true dir_path with | `Directory -> let entries = Eio.Path.read_dir dir_path in let json_files = List.filter (fun f -> String.length f = 13 && String.ends_with ~suffix:".json" f && not (String.contains f '-')) entries in let sorted = List.sort String.compare json_files in let from_file = filename_of_date from_date in let to_file = filename_of_date to_date in let in_range = List.filter (fun f -> f >= from_file && f <= to_file) sorted in let results = List.filter_map (fun filename -> match date_of_filename filename with | Some date -> ( match load ~fs ~changes_dir ~date with | Ok t -> Some t | Error _ -> None) | None -> None) in_range in Ok results | _ -> Error "Changes directory not found" | exception Eio.Io _ -> Error "Could not read changes directory" let latest ~fs ~changes_dir = let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in match Eio.Path.kind ~follow:true dir_path with | `Directory -> ( let entries = Eio.Path.read_dir dir_path in let json_files = List.filter (fun f -> String.length f = 13 && String.ends_with ~suffix:".json" f && not (String.contains f '-')) entries in match List.sort (fun a b -> String.compare b a) json_files with | [] -> Ok None | latest_file :: _ -> ( match date_of_filename latest_file with | Some date -> ( match load ~fs ~changes_dir ~date with | Ok t -> Ok (Some t) | Error e -> Error e) | None -> Ok None)) | _ -> Ok None | exception Eio.Io _ -> Ok None let ensure_dir ~fs dir = let path = Eio.Path.(fs / Fpath.to_string dir) in match Eio.Path.kind ~follow:true path with | `Directory -> () | _ -> Eio.Path.mkdir ~perm:0o755 path | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 path let save ~fs ~changes_dir t = ensure_dir ~fs changes_dir; let filename = filename_of_date t.date in let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in match Jsont_bytesrw.encode_string ~format:Jsont.Indent jsont t 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)