My aggregated monorepo of OCaml code, automaintained
at http2 253 lines 9.1 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** Daily changes with per-day-per-repo structure. 7 8 This module provides an immutable data structure for loading and querying 9 daily changes from per-day-per-repo JSON files. Files are named 10 [<repo>-<YYYY-MM-DD>.json] and contain timestamped entries for real-time 11 tracking. *) 12 13type commit_range = { 14 from_hash : string; 15 to_hash : string; 16 count : int; 17} 18 19type entry = { 20 repository : string; 21 hour : int; 22 timestamp : Ptime.t; 23 summary : string; 24 changes : string list; 25 commit_range : commit_range; 26 contributors : string list; 27 repo_url : string option; 28} 29 30type day = { 31 repository : string; 32 date : string; 33 entries : entry list; 34} 35 36module String_map = Map.Make(String) 37 38type t = { 39 by_repo : day list String_map.t; 40 by_date : day list String_map.t; 41 all_entries : entry list; 42} 43 44(* JSON codecs for the per-day file format *) 45 46let commit_range_jsont = 47 let make from_hash to_hash count = { from_hash; to_hash; count } in 48 Jsont.Object.map ~kind:"commit_range" make 49 |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash) 50 |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash) 51 |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count) 52 |> Jsont.Object.finish 53 54let ptime_jsont = 55 let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in 56 let dec s = 57 match Ptime.of_rfc3339 s with 58 | Ok (t, _, _) -> t 59 | Error _ -> failwith ("Invalid timestamp: " ^ s) 60 in 61 Jsont.map ~dec ~enc Jsont.string 62 63(* Entry codec for the file format (without repository, added during load) *) 64type file_entry = { 65 hour : int; 66 timestamp : Ptime.t; 67 summary : string; 68 changes : string list; 69 commit_range : commit_range; 70 contributors : string list; 71 repo_url : string option; 72} 73 74let file_entry_jsont = 75 let make hour timestamp summary changes commit_range contributors repo_url = 76 { hour; timestamp; summary; changes; commit_range; contributors; repo_url } 77 in 78 let default_hour = 0 in 79 let default_timestamp = Ptime.epoch in 80 Jsont.Object.map ~kind:"daily_entry" make 81 |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour) 82 |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp) 83 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary) 84 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes) 85 |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range) 86 |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors) 87 |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url) 88 |> Jsont.Object.finish 89 90type json_file = { 91 json_repository : string; 92 json_entries : file_entry list; 93} 94 95let json_file_jsont = 96 let make json_repository json_entries = { json_repository; json_entries } in 97 Jsont.Object.map ~kind:"daily_changes_file" make 98 |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun f -> f.json_repository) 99 |> Jsont.Object.mem "entries" (Jsont.list file_entry_jsont) ~enc:(fun f -> f.json_entries) 100 |> Jsont.Object.finish 101 102(* Parse date from filename: <repo>-<YYYY-MM-DD>.json *) 103let parse_daily_filename filename = 104 (* Check for pattern: ends with -YYYY-MM-DD.json *) 105 let len = String.length filename in 106 if len < 16 || not (String.ends_with ~suffix:".json" filename) then 107 None 108 else 109 (* Try to extract date: last 15 chars are -YYYY-MM-DD.json *) 110 let date_start = len - 15 in 111 let potential_date = String.sub filename (date_start + 1) 10 in 112 (* Validate date format YYYY-MM-DD *) 113 if String.length potential_date = 10 && 114 potential_date.[4] = '-' && potential_date.[7] = '-' then 115 let repo = String.sub filename 0 date_start in 116 Some (repo, potential_date) 117 else 118 None 119 120(* Load a single daily file *) 121let load_file ~fs ~changes_dir ~repo ~date : entry list = 122 let filename = repo ^ "-" ^ date ^ ".json" in 123 let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in 124 match Eio.Path.kind ~follow:true file_path with 125 | `Regular_file -> ( 126 let content = Eio.Path.load file_path in 127 match Jsont_bytesrw.decode_string json_file_jsont content with 128 | Ok jf -> 129 List.map (fun (fe : file_entry) : entry -> 130 { repository = repo; 131 hour = fe.hour; 132 timestamp = fe.timestamp; 133 summary = fe.summary; 134 changes = fe.changes; 135 commit_range = fe.commit_range; 136 contributors = fe.contributors; 137 repo_url = fe.repo_url; 138 }) jf.json_entries 139 | Error _ -> []) 140 | _ -> [] 141 | exception Eio.Io _ -> [] 142 143let empty = { 144 by_repo = String_map.empty; 145 by_date = String_map.empty; 146 all_entries = []; 147} 148 149let list_repos ~fs ~changes_dir = 150 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 151 match Eio.Path.kind ~follow:true dir_path with 152 | `Directory -> 153 let files = Eio.Path.read_dir dir_path in 154 files 155 |> List.filter_map parse_daily_filename 156 |> List.map fst 157 |> List.sort_uniq String.compare 158 | _ -> [] 159 | exception Eio.Io _ -> [] 160 161let list_dates ~fs ~changes_dir ~repo = 162 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 163 match Eio.Path.kind ~follow:true dir_path with 164 | `Directory -> 165 let files = Eio.Path.read_dir dir_path in 166 files 167 |> List.filter_map (fun filename -> 168 match parse_daily_filename filename with 169 | Some (r, date) when r = repo -> Some date 170 | _ -> None) 171 |> List.sort (fun a b -> String.compare b a) (* descending *) 172 | _ -> [] 173 | exception Eio.Io _ -> [] 174 175let load_repo_day ~fs ~changes_dir ~repo ~date = 176 load_file ~fs ~changes_dir ~repo ~date 177 178let load_repo_all ~fs ~changes_dir ~repo = 179 let dates = list_dates ~fs ~changes_dir ~repo in 180 List.concat_map (fun date -> load_file ~fs ~changes_dir ~repo ~date) dates 181 182let load_all ~fs ~changes_dir = 183 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 184 match Eio.Path.kind ~follow:true dir_path with 185 | `Directory -> 186 let files = Eio.Path.read_dir dir_path in 187 let parsed_files = List.filter_map parse_daily_filename files in 188 189 (* Load all files and build days *) 190 let days : day list = List.filter_map (fun (repo, date) -> 191 let loaded_entries : entry list = load_file ~fs ~changes_dir ~repo ~date in 192 if loaded_entries = [] then None 193 else 194 let sorted_entries : entry list = List.sort (fun (e1 : entry) (e2 : entry) -> 195 Ptime.compare e1.timestamp e2.timestamp) loaded_entries 196 in 197 Some ({ repository = repo; date; entries = sorted_entries } : day) 198 ) parsed_files in 199 200 (* Build by_repo map *) 201 let by_repo : day list String_map.t = List.fold_left (fun acc (d : day) -> 202 let existing = String_map.find_opt d.repository acc |> Option.value ~default:[] in 203 String_map.add d.repository (d :: existing) acc 204 ) String_map.empty days in 205 206 (* Sort each repo's days by date descending *) 207 let by_repo : day list String_map.t = String_map.map (fun (ds : day list) -> 208 List.sort (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date) ds 209 ) by_repo in 210 211 (* Build by_date map *) 212 let by_date : day list String_map.t = List.fold_left (fun acc (d : day) -> 213 let existing = String_map.find_opt d.date acc |> Option.value ~default:[] in 214 String_map.add d.date (d :: existing) acc 215 ) String_map.empty days in 216 217 (* Sort each date's days by repo name *) 218 let by_date : day list String_map.t = String_map.map (fun (ds : day list) -> 219 List.sort (fun (d1 : day) (d2 : day) -> String.compare d1.repository d2.repository) ds 220 ) by_date in 221 222 (* Collect all entries sorted by timestamp *) 223 let all_entries : entry list = 224 days 225 |> List.concat_map (fun (d : day) -> d.entries) 226 |> List.sort (fun (e1 : entry) (e2 : entry) -> Ptime.compare e1.timestamp e2.timestamp) 227 in 228 229 { by_repo; by_date; all_entries } 230 231 | _ -> empty 232 | exception Eio.Io _ -> empty 233 234let since (t : t) (timestamp : Ptime.t) : entry list = 235 List.filter (fun (e : entry) -> Ptime.compare e.timestamp timestamp > 0) t.all_entries 236 237let for_repo t repo = 238 String_map.find_opt repo t.by_repo |> Option.value ~default:[] 239 240let for_date t date = 241 String_map.find_opt date t.by_date |> Option.value ~default:[] 242 243let repos t = 244 String_map.bindings t.by_repo |> List.map fst 245 246let dates t = 247 String_map.bindings t.by_date 248 |> List.map fst 249 |> List.sort (fun a b -> String.compare b a) (* descending *) 250 251let entries_since ~fs ~changes_dir ~since:timestamp = 252 let t = load_all ~fs ~changes_dir in 253 since t timestamp