Monorepo management for opam overlays
at main 244 lines 8.3 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** Aggregated daily changes format. 7 8 This module provides types and JSON codecs for the aggregated daily changes 9 format stored in [.changes/YYYYMMDD.json] files. These files combine all 10 repository changes for a single day into a structured format suitable for 11 broadcasting. *) 12 13type change_type = 14 | Feature 15 | Bugfix 16 | Documentation 17 | Refactor 18 | New_library 19 | Unknown 20 21let change_type_of_string = function 22 | "feature" -> Feature 23 | "bugfix" -> Bugfix 24 | "documentation" -> Documentation 25 | "refactor" -> Refactor 26 | "new_library" -> New_library 27 | _ -> Unknown 28 29let string_of_change_type = function 30 | Feature -> "feature" 31 | Bugfix -> "bugfix" 32 | Documentation -> "documentation" 33 | Refactor -> "refactor" 34 | New_library -> "new_library" 35 | Unknown -> "unknown" 36 37type commit_range = { from_hash : string; to_hash : string; count : int } 38 39type entry = { 40 repository : string; 41 hour : int; 42 timestamp : Ptime.t; 43 summary : string; 44 changes : string list; 45 commit_range : commit_range; 46 contributors : string list; 47 repo_url : string option; 48 change_type : change_type; 49} 50 51type t = { 52 date : string; 53 generated_at : Ptime.t; 54 git_head : string; 55 entries : entry list; 56 authors : string list; 57} 58 59(* JSON codecs *) 60 61let change_type_jsont = 62 Jsont.enum ~kind:"change_type" 63 [ 64 ("feature", Feature); 65 ("bugfix", Bugfix); 66 ("documentation", Documentation); 67 ("refactor", Refactor); 68 ("new_library", New_library); 69 ("unknown", Unknown); 70 ] 71 72let commit_range_jsont = 73 let make from_hash to_hash count = { from_hash; to_hash; count } in 74 Jsont.Object.map ~kind:"commit_range" make 75 |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash) 76 |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash) 77 |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count) 78 |> Jsont.Object.finish 79 80let ptime_jsont = 81 let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in 82 let dec s = 83 match Ptime.of_rfc3339 s with 84 | Ok (t, _, _) -> t 85 | Error _ -> failwith ("Invalid timestamp: " ^ s) 86 in 87 Jsont.map ~dec ~enc Jsont.string 88 89let entry_jsont = 90 let make repository hour timestamp summary changes commit_range contributors 91 repo_url change_type = 92 { 93 repository; 94 hour; 95 timestamp; 96 summary; 97 changes; 98 commit_range; 99 contributors; 100 repo_url; 101 change_type; 102 } 103 in 104 (* Default hour and timestamp for backwards compat when reading old files *) 105 let default_hour = 0 in 106 let default_timestamp = Ptime.epoch in 107 Jsont.Object.map ~kind:"aggregated_entry" make 108 |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun e -> e.repository) 109 |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> 110 e.hour) 111 |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp 112 ~enc:(fun e -> e.timestamp) 113 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary) 114 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> 115 e.changes) 116 |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> 117 e.commit_range) 118 |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] 119 ~enc:(fun e -> e.contributors) 120 |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None 121 ~enc:(fun e -> e.repo_url) 122 |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown 123 ~enc:(fun e -> e.change_type) 124 |> Jsont.Object.finish 125 126let jsont = 127 let make date generated_at git_head entries authors = 128 { date; generated_at; git_head; entries; authors } 129 in 130 Jsont.Object.map ~kind:"aggregated_changes" make 131 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun t -> t.date) 132 |> Jsont.Object.mem "generated_at" ptime_jsont ~enc:(fun t -> t.generated_at) 133 |> Jsont.Object.mem "git_head" Jsont.string ~enc:(fun t -> t.git_head) 134 |> Jsont.Object.mem "entries" (Jsont.list entry_jsont) ~enc:(fun t -> 135 t.entries) 136 |> Jsont.Object.mem "authors" (Jsont.list Jsont.string) ~dec_absent:[] 137 ~enc:(fun t -> t.authors) 138 |> Jsont.Object.finish 139 140(* File I/O *) 141 142let filename_of_date date = 143 (* date is in YYYY-MM-DD format, convert to YYYYMMDD.json *) 144 let clean = String.concat "" (String.split_on_char '-' date) in 145 clean ^ ".json" 146 147let date_of_filename filename = 148 (* YYYYMMDD.json -> YYYY-MM-DD *) 149 if String.length filename >= 12 && String.sub filename 8 5 = ".json" then 150 let yyyymmdd = String.sub filename 0 8 in 151 let yyyy = String.sub yyyymmdd 0 4 in 152 let mm = String.sub yyyymmdd 4 2 in 153 let dd = String.sub yyyymmdd 6 2 in 154 Some (yyyy ^ "-" ^ mm ^ "-" ^ dd) 155 else None 156 157let load ~fs ~changes_dir ~date = 158 let filename = filename_of_date date in 159 let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in 160 match Eio.Path.kind ~follow:true file_path with 161 | `Regular_file -> ( 162 let content = Eio.Path.load file_path in 163 match Jsont_bytesrw.decode_string jsont content with 164 | Ok t -> Ok t 165 | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e)) 166 | _ -> Error (Format.sprintf "File not found: %s" filename) 167 | exception Eio.Io _ -> Error (Format.sprintf "Could not read %s" filename) 168 169let load_range ~fs ~changes_dir ~from_date ~to_date = 170 (* List all YYYYMMDD.json files and filter by range *) 171 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 172 match Eio.Path.kind ~follow:true dir_path with 173 | `Directory -> 174 let entries = Eio.Path.read_dir dir_path in 175 let json_files = 176 List.filter 177 (fun f -> 178 String.length f = 13 179 && String.ends_with ~suffix:".json" f 180 && not (String.contains f '-')) 181 entries 182 in 183 let sorted = List.sort String.compare json_files in 184 let from_file = filename_of_date from_date in 185 let to_file = filename_of_date to_date in 186 let in_range = 187 List.filter (fun f -> f >= from_file && f <= to_file) sorted 188 in 189 let results = 190 List.filter_map 191 (fun filename -> 192 match date_of_filename filename with 193 | Some date -> ( 194 match load ~fs ~changes_dir ~date with 195 | Ok t -> Some t 196 | Error _ -> None) 197 | None -> None) 198 in_range 199 in 200 Ok results 201 | _ -> Error "Changes directory not found" 202 | exception Eio.Io _ -> Error "Could not read changes directory" 203 204let latest ~fs ~changes_dir = 205 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 206 match Eio.Path.kind ~follow:true dir_path with 207 | `Directory -> ( 208 let entries = Eio.Path.read_dir dir_path in 209 let json_files = 210 List.filter 211 (fun f -> 212 String.length f = 13 213 && String.ends_with ~suffix:".json" f 214 && not (String.contains f '-')) 215 entries 216 in 217 match List.sort (fun a b -> String.compare b a) json_files with 218 | [] -> Ok None 219 | latest_file :: _ -> ( 220 match date_of_filename latest_file with 221 | Some date -> ( 222 match load ~fs ~changes_dir ~date with 223 | Ok t -> Ok (Some t) 224 | Error e -> Error e) 225 | None -> Ok None)) 226 | _ -> Ok None 227 | exception Eio.Io _ -> Ok None 228 229let ensure_dir ~fs dir = 230 let path = Eio.Path.(fs / Fpath.to_string dir) in 231 match Eio.Path.kind ~follow:true path with 232 | `Directory -> () 233 | _ -> Eio.Path.mkdir ~perm:0o755 path 234 | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 path 235 236let save ~fs ~changes_dir t = 237 ensure_dir ~fs changes_dir; 238 let filename = filename_of_date t.date in 239 let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in 240 match Jsont_bytesrw.encode_string ~format:Jsont.Indent jsont t with 241 | Ok content -> 242 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content; 243 Ok () 244 | Error e -> Error (Format.sprintf "Failed to encode %s: %s" filename e)