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