···11# Daily Changelog
2233+## 2026-01-19
44+55+### New Libraries
66+77+- **[ocaml-webfinger](https://tangled.org/@anil.recoil.org/ocaml-webfinger.git)**: OCaml implementation of RFC 7033 WebFinger protocol for discovering information about resources using standard HTTP. Includes abstract Link and JRD types with jsont JSON encoding/decoding, nullable property support, and a command-line interface built with cmdliner. Uses [ocaml-requests](https://tangled.org/@anil.recoil.org/ocaml-requests.git) for HTTP operations. — *Anil Madhavapeddy*
88+99+### Major Features
1010+1111+- **[ocaml-apubt](https://tangled.org/@anil.recoil.org/ocaml-apubt.git)**: Added complete authentication system with XDG-compliant credential storage in ~/.config/apub/. New CLI commands for auth (setup/status/logout), profile management (list/switch/current), and write operations (post, follow, like, boost) with auto-loaded credentials. Integrated [ocaml-webfinger](https://tangled.org/@anil.recoil.org/ocaml-webfinger.git) for RFC 7033/7565 compliant actor discovery. Added Question activity support with one_of, any_of, closed fields. — *Anil Madhavapeddy*
1212+1313+- **[poe](https://tangled.org/@anil.recoil.org/poe.git)**: Added automated changes broadcast system with new `poe loop --interval` command for hourly change broadcasting. New admin commands (last-broadcast, reset-broadcast, storage keys/get/delete), Commands module with deterministic parsing, and Broadcast module for smart change detection that only sends new changes. Added config options for admin_emails and changes_dir. — *Anil Madhavapeddy*
1414+1515+- **[monopam](https://tangled.org/@anil.recoil.org/monopam.git)**: Added monopam_changes library with Aggregated and Query modules for structured changelog format. New --aggregate flag for `monopam changes --daily` producing structured JSON output. Daily module with Map-based indexes and query functions (since, for_repo, for_date). Changed daily files from <repo>-daily.json to <repo>-<date>.json with hour tracking. — *Anil Madhavapeddy*
1616+1717+### Bug Fixes
1818+1919+- **[ocaml-atp](https://tangled.org/@anil.recoil.org/ocaml-atp.git)**: Fixed non-deterministic code generation in hermest lexicon generator by sorting alphabetically. Regenerated all lexicon files with deterministic ordering. — *Anil Madhavapeddy*
2020+2121+### Code Quality Improvements
2222+2323+- **[ocaml-zulip](https://tangled.org/@anil.recoil.org/ocaml-zulip.git)**: Improved bot functionality and cleaned up build configuration by removing public_name/package from test and example executables. — *Anil Madhavapeddy*
2424+2525+---
2626+327## 2026-01-18
428529### New Libraries
63077-- **[ocaml-frontmatter](https://tangled.org/@anil.recoil.org/ocaml-frontmatter.git)**: OCaml library for parsing YAML/TOML frontmatter from Markdown and other document formats — commonly used for static site generators and content management systems. — *Anil Madhavapeddy*
3131+- **[ocaml-mail-flag](https://tangled.org/@anil.recoil.org/ocaml-mail-flag.git)**: Unified library for parsing and manipulating email flags across protocols. Provides shared Keyword, Mailbox_attr, and Flag_color modules used by both [ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git) and [ocaml-jmap](https://tangled.org/@anil.recoil.org/ocaml-jmap.git) for IMAP/JMAP interoperability. — *Anil Madhavapeddy*
83299-- **[ocaml-bushel](https://tangled.org/@anil.recoil.org/ocaml-bushel.git)**: OCaml implementation of the Bushel document format — a structured approach to organizing and managing document collections. — *Anil Madhavapeddy*
3333+- **[ocaml-frontmatter](https://tangled.org/@anil.recoil.org/ocaml-frontmatter.git)**: OCaml library for parsing YAML and TOML frontmatter in documents, useful for static site generators and document processors. Uses [ocaml-yamlt](https://tangled.org/@anil.recoil.org/ocaml-yamlt.git) for YAML parsing. — *Anil Madhavapeddy*
10341111-### Email Protocol Libraries
3535+- **[ocaml-bushel](https://tangled.org/@anil.recoil.org/ocaml-bushel.git)**: Added ocaml-bushel library to the monorepo. — *Anil Madhavapeddy*
12361313-- **[ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git)**: Major RFC compliance update with comprehensive extension support — added SORT/THREAD (RFC 5256), CONDSTORE (RFC 7162), QUOTA (RFC 9208), ESEARCH (RFC 4731), and LIST-EXTENDED (RFC 5258). Fixed SEARCH response parsing and APPEND literal synchronization bugs. — *Mark Elvers* and *Anil Madhavapeddy*
3737+### Email Protocol Improvements
14381515-- **[ocaml-jmap](https://tangled.org/@anil.recoil.org/ocaml-jmap.git)**: Added mail-flag library for unified email flag handling across IMAP and JMAP protocols — includes RFC 8621 keywords (Seen, Flagged, Draft), RFC 6154/5258 mailbox roles (Inbox, Sent, Trash), Apple Mail color flags, and protocol-specific serialization adapters. This library bridges [ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git) and [ocaml-jmap](https://tangled.org/@anil.recoil.org/ocaml-jmap.git) for cross-protocol email applications. — *Anil Madhavapeddy*
3939+- **[ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git)**: Major expansion of IMAP RFC compliance with ESEARCH, THREAD, QUOTA, LIST-EXTENDED, UTF-8, and CONDSTORE extensions. Added SORT command with sort keys (Arrival, Date, From, Size, Subject, To). Fixed SEARCH response parsing and APPEND literal synchronization (LITERAL+). Added BODY/BODYSTRUCTURE recursive MIME parsing with section specifiers. Integrated [ocaml-mail-flag](https://tangled.org/@anil.recoil.org/ocaml-mail-flag.git) for shared keyword/mailbox types. — *Anil Madhavapeddy*
16401717-### Configuration & XDG Improvements
4141+- **[ocaml-jmap](https://tangled.org/@anil.recoil.org/ocaml-jmap.git)**: Integrated [ocaml-mail-flag](https://tangled.org/@anil.recoil.org/ocaml-mail-flag.git) library for unified email flag handling. Added RFC 8621 keywords (Seen, Answered, Flagged, Draft, Forwarded, Phishing) and RFC 6154 mailbox roles (Inbox, Drafts, Sent, Trash, Archive, Snoozed). Added role/special_use conversion functions to Mail_mailbox and keywords conversion to Mail_email modules. — *Anil Madhavapeddy*
18421919-- **[poe](https://tangled.org/@anil.recoil.org/poe.git)**: Unified all configuration under `~/.config/poe/` directory with renamed config files for clarity. — *Anil Madhavapeddy*
4343+### Configuration & Tooling
20442121-- **[ocaml-zulip](https://tangled.org/@anil.recoil.org/ocaml-zulip.git)**: Added `xdg_app` parameter to Config module for custom XDG directory paths; renamed config file from "config" to "zulip.config". — *Anil Madhavapeddy*
4545+- **[poe](https://tangled.org/@anil.recoil.org/poe.git)**: Configuration now stored under unified XDG path (~/.config/poe/). Added xdg_app parameter to zulip-bot Config and renamed zulip config file from "config" to "zulip.config". — *Anil Madhavapeddy*
22462323-### API Improvements
4747+- **[ocaml-zulip](https://tangled.org/@anil.recoil.org/ocaml-zulip.git)**: Config module now supports Zulip's native [api] section format alongside existing formats. Added xdg_app parameter for custom XDG config paths. Config loading tries [bot], then [api], then bare format automatically. — *Anil Madhavapeddy*
24482525-- **[ocaml-matrix](https://tangled.org/@anil.recoil.org/ocaml-matrix.git)**: Added pretty-printers, accessors, and 13 missing `.mli` interface files — improves API usability with `pp` functions for Matrix_id modules, `make` constructors for event types, and clean odoc builds. — *Anil Madhavapeddy*
4949+- **[monopam](https://tangled.org/@anil.recoil.org/monopam.git)**: Improved push workflow with auto-clone of upstream repos when checkout missing. Fixed tangled.org URL parsing to strip @ prefix from usernames. — *Anil Madhavapeddy*
26502727-- **[ocaml-yamlt](https://tangled.org/@anil.recoil.org/ocaml-yamlt.git)**: Added convenience functions `decode_string`, `decode_value`, and `decode_value'` for more flexible YAML decoding. — *Anil Madhavapeddy*
5151+### API Improvements
28522929-### Tooling
5353+- **[ocaml-yamlt](https://tangled.org/@anil.recoil.org/ocaml-yamlt.git)**: Added convenience functions decode_string, decode_value, and decode_value' for decoding YAML directly from strings and pre-parsed Yamlrw.value types. — *Anil Madhavapeddy*
30543131-- **[monopam](https://tangled.org/@anil.recoil.org/monopam.git)**: `monopam push` now auto-creates missing checkout directories — new packages no longer require manual pull first. — *Anil Madhavapeddy*
5555+- **[ocaml-matrix](https://tangled.org/@anil.recoil.org/ocaml-matrix.git)**: Added pp functions to matrix_id modules (User_id, Room_id, etc.), make constructors and accessors to event content types. Added 13 missing .mli files for matrix_client modules. Fixed odoc documentation warnings for clean @doc-full builds. — *Anil Madhavapeddy*
32563333-- **[ocaml-atp](https://tangled.org/@anil.recoil.org/ocaml-atp.git)**: Regenerated AT Protocol lexicon bindings (atproto, bsky, standard-site, tangled) from upstream schemas. — *Anil Madhavapeddy*
5757+- **[ocaml-atp](https://tangled.org/@anil.recoil.org/ocaml-atp.git)**: Regenerated OCaml bindings for atproto, bsky, standard-site, and tangled lexicons. — *Anil Madhavapeddy*
34583559---
3660···38623963### Major Features
40644141-- **[ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git)**: Major restructuring with critical bug fixes — reorganized into `lib/imap/` (client) and `lib/imapd/` (server), fixed RECENT response parsing that was overwriting EXISTS count, changed UIDs/UIDVALIDITY from int32 to int64 for RFC 9051 compliance, added Logs integration and AUTHENTICATE PLAIN support. — *Anil Madhavapeddy*
4242-4343-- **[monopam](https://tangled.org/@anil.recoil.org/monopam.git)**: New `monopam changes` command uses AI to generate changelogs from git history — added Changes module with jsont codecs, Git.log with date filtering, and aggregated CHANGES.md generation at monorepo root. — *Anil Madhavapeddy*
6565+- **[monopam](https://tangled.org/@anil.recoil.org/monopam.git)**: New 'monopam changes' command generates AI-powered changelogs from git history. Added Changes module with jsont codecs for changelog serialization, Git.log function with date filtering, Claude AI integration for intelligent commit analysis, and aggregated CHANGES.md generation at monorepo root. — *Anil Madhavapeddy*
44664545-### Bug Fixes
6767+### Critical Bug Fixes
46684747-- **[ocaml-requests](https://tangled.org/@anil.recoil.org/ocaml-requests.git)**: Fixed missing `Uri` module re-export that caused build errors. — *Anil Madhavapeddy*
6969+- **[ocaml-imap](https://tangled.org/@anil.recoil.org/ocaml-imap.git)**: Fixed RECENT response parsing that was overwriting EXISTS count. Changed UIDs and UIDVALIDITY to int64 to handle values up to 4294967295. Fixed writer lifecycle bug causing "cannot write to closed writer" errors. Added Logs library integration for debugging. Reorganized lib/ into imap/ (client) and imapd/ (server) with clearer module names. — *Anil Madhavapeddy*
48704949-### Code Quality
7171+- **[ocaml-requests](https://tangled.org/@anil.recoil.org/ocaml-requests.git)**: Fixed missing Uri module re-export that caused build errors. — *Anil Madhavapeddy*
50725151-- **[ocaml-conpool](https://tangled.org/@anil.recoil.org/ocaml-conpool.git)**: Refactored `is_healthy` function to reduce nesting and improve readability. — *Anil Madhavapeddy*
7373+### Code Quality Improvements
52745353-### Documentation
7575+- **[ocaml-conpool](https://tangled.org/@anil.recoil.org/ocaml-conpool.git)**: Refactored is_healthy function to reduce nesting and improve clarity. — *Anil Madhavapeddy*
54765577- **[ocaml-zulip](https://tangled.org/@anil.recoil.org/ocaml-zulip.git)**: Improved retention type documentation in channels.mli. — *Anil Madhavapeddy*
56785757-- **[ocaml-langdetect](https://tangled.org/@anil.recoil.org/ocaml-langdetect.git)**: Fixed language count in dune-project to correctly report 49 supported languages. — *Anil Madhavapeddy*
5858-5959-- **[ocaml-punycode](https://tangled.org/@anil.recoil.org/ocaml-punycode.git)**: Added README documenting IDNA features not yet implemented. — *Anil Madhavapeddy*
6060-6161-- **[ocaml-jsonwt](https://tangled.org/@anil.recoil.org/ocaml-jsonwt.git)**: Added README documentation. — *Anil Madhavapeddy*
7979+### Documentation Updates
62806363-- **[ocaml-owntracks](https://tangled.org/@anil.recoil.org/ocaml-owntracks.git)**: Added README documentation. — *Anil Madhavapeddy*
6464-6565-- **[srcsetter](https://tangled.org/@anil.recoil.org/srcsetter.git)**: Added README documentation. — *Anil Madhavapeddy*
8181+- **[srcsetter](https://tangled.org/@anil.recoil.org/srcsetter.git)**: Added README documentation for the library. — *Anil Madhavapeddy*
8282+- **[ocaml-punycode](https://tangled.org/@anil.recoil.org/ocaml-punycode.git)**: Added README documenting unimplemented IDNA 2008 features. — *Anil Madhavapeddy*
8383+- **[ocaml-owntracks](https://tangled.org/@anil.recoil.org/ocaml-owntracks.git)**: Added README file documenting library purpose and usage. — *Anil Madhavapeddy*
8484+- **[ocaml-jsonwt](https://tangled.org/@anil.recoil.org/ocaml-jsonwt.git)**: Added README file documenting the jsonwt library. — *Anil Madhavapeddy*
8585+- **[ocaml-langdetect](https://tangled.org/@anil.recoil.org/ocaml-langdetect.git)**: Fixed language count accuracy (47→49) in dune-project synopsis. — *Anil Madhavapeddy*
+50-18
monopam/lib/changes.ml
···5566 Changes are stored in a .changes directory at the monorepo root:
77 - .changes/<repo_name>.json - weekly changelog entries
88- - .changes/<repo_name>-daily.json - daily changelog entries *)
88+ - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file per day per repo) *)
991010type commit_range = {
1111 from_hash : string;
···23232424type daily_entry = {
2525 date : string; (* ISO date YYYY-MM-DD *)
2626+ hour : int; (* Hour of day 0-23 *)
2727+ timestamp : Ptime.t; (* RFC3339 timestamp for precise ordering *)
2628 summary : string; (* One-line summary *)
2729 changes : string list; (* Bullet points *)
2830 commit_range : commit_range;
···7274 |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont) ~enc:(fun (f : changes_file) -> f.entries)
7375 |> Jsont.Object.finish
74767777+let ptime_jsont =
7878+ let enc t =
7979+ Ptime.to_rfc3339 t ~tz_offset_s:0
8080+ in
8181+ let dec s =
8282+ match Ptime.of_rfc3339 s with
8383+ | Ok (t, _, _) -> t
8484+ | Error _ -> failwith ("Invalid timestamp: " ^ s)
8585+ in
8686+ Jsont.map ~dec ~enc Jsont.string
8787+7588let daily_entry_jsont : daily_entry Jsont.t =
7676- let make date summary changes commit_range contributors repo_url : daily_entry =
7777- { date; summary; changes; commit_range; contributors; repo_url }
8989+ let make date hour timestamp summary changes commit_range contributors repo_url : daily_entry =
9090+ { date; hour; timestamp; summary; changes; commit_range; contributors; repo_url }
7891 in
9292+ (* Default hour and timestamp for backwards compat when reading old files *)
9393+ let default_hour = 0 in
9494+ let default_timestamp = Ptime.epoch in
7995 Jsont.Object.map ~kind:"daily_entry" make
8096 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun (e : daily_entry) -> e.date)
9797+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun (e : daily_entry) -> e.hour)
9898+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun (e : daily_entry) -> e.timestamp)
8199 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) -> e.summary)
82100 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : daily_entry) -> e.changes)
83101 |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : daily_entry) -> e.commit_range)
···124142 Ok ()
125143 | Error e -> Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e)
126144127127-(* Load daily changes from .changes/<repo>-daily.json in monorepo *)
128128-let load_daily ~fs ~monorepo repo_name =
129129- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ "-daily.json")) in
145145+(* Filename for daily changes: <repo>-<YYYY-MM-DD>.json *)
146146+let daily_filename repo_name date =
147147+ repo_name ^ "-" ^ date ^ ".json"
148148+149149+(* Load daily changes from .changes/<repo>-<date>.json in monorepo *)
150150+let load_daily ~fs ~monorepo ~date repo_name =
151151+ let filename = daily_filename repo_name date in
152152+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in
130153 match Eio.Path.kind ~follow:true file_path with
131154 | `Regular_file -> (
132155 let content = Eio.Path.load file_path in
133156 match Jsont_bytesrw.decode_string daily_changes_file_jsont content with
134157 | Ok cf -> Ok cf
135135- | Error e -> Error (Format.sprintf "Failed to parse %s-daily.json: %s" repo_name e))
158158+ | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e))
136159 | _ -> Ok { repository = repo_name; entries = [] }
137160 | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
138161139139-(* Save daily changes to .changes/<repo>-daily.json in monorepo *)
140140-let save_daily ~fs ~monorepo (cf : daily_changes_file) =
162162+(* Save daily changes to .changes/<repo>-<date>.json in monorepo *)
163163+let save_daily ~fs ~monorepo ~date (cf : daily_changes_file) =
141164 ensure_changes_dir ~fs monorepo;
142142- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ "-daily.json")) in
165165+ let filename = daily_filename cf.repository date in
166166+ let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in
143167 match Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf with
144168 | Ok content ->
145169 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
146170 Ok ()
147147- | Error e -> Error (Format.sprintf "Failed to encode %s-daily.json: %s" cf.repository e)
171171+ | Error e -> Error (Format.sprintf "Failed to encode %s: %s" filename e)
148172149173(* Markdown generation *)
150174···276300 let (y, m, d), _ = Ptime.to_date_time t in
277301 format_date (y, m, d)
278302279279-let has_day (cf : daily_changes_file) ~date =
280280- List.exists (fun (e : daily_entry) -> e.date = date) cf.entries
303303+let has_day (cf : daily_changes_file) ~date:_ =
304304+ (* With per-day files, the file is already for a specific date.
305305+ This function now checks if the file has any entries. *)
306306+ cf.entries <> []
281307282308(* Aggregate daily changes into DAILY-CHANGES.md *)
283309let aggregate_daily ~history (cfs : daily_changes_file list) =
···726752let generate_aggregated ~fs ~monorepo ~date ~git_head =
727753 let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
728754729729- (* List all *-daily.json files *)
755755+ (* List all *-<date>.json files (new per-day format) *)
730756 let files =
731757 try Eio.Path.read_dir changes_dir
732758 with Eio.Io _ -> []
733759 in
760760+ (* Match files like "<repo>-2026-01-19.json" for the given date *)
761761+ let date_suffix = "-" ^ date ^ ".json" in
762762+ let date_suffix_len = String.length date_suffix in
734763 let daily_files = List.filter (fun f ->
735735- String.ends_with ~suffix:"-daily.json" f) files
764764+ String.ends_with ~suffix:date_suffix f && String.length f > date_suffix_len) files
736765 in
737766738738- (* Load all daily files and collect entries for the target date *)
767767+ (* Load all daily files for this date and collect entries *)
739768 let entries = List.concat_map (fun filename ->
740740- let repo_name = String.sub filename 0 (String.length filename - 11) in
769769+ (* Extract repo name: filename is "<repo>-<date>.json" *)
770770+ let repo_name = String.sub filename 0 (String.length filename - date_suffix_len) in
741771 let path = Eio.Path.(changes_dir / filename) in
742772 try
743773 let content = Eio.Path.load path in
744774 match Jsont_bytesrw.decode_string daily_changes_file_jsont content with
745775 | Ok dcf ->
746776 List.filter_map (fun (e : daily_entry) ->
747747- if e.date = date && e.changes <> [] then
777777+ if e.changes <> [] then
748778 Some (repo_name, e)
749779 else
750780 None) dcf.entries
···758788 let change_type = infer_change_type e.summary in
759789 Monopam_changes.Aggregated.{
760790 repository = repo_name;
791791+ hour = e.hour;
792792+ timestamp = e.timestamp;
761793 summary = e.summary;
762794 changes = e.changes;
763795 commit_range = {
+11-7
monopam/lib/changes.mli
···5566 Changes are stored in a .changes directory at the monorepo root:
77 - .changes/<repo_name>.json - weekly changelog entries
88- - .changes/<repo_name>-daily.json - daily changelog entries *)
88+ - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file per day per repo) *)
991010(** {1 Types} *)
1111···27272828type daily_entry = {
2929 date : string; (** ISO date YYYY-MM-DD *)
3030+ hour : int; (** Hour of day 0-23 for filtering *)
3131+ timestamp : Ptime.t; (** RFC3339 timestamp for precise ordering *)
3032 summary : string; (** One-line summary *)
3133 changes : string list; (** Bullet points *)
3234 commit_range : commit_range;
3335 contributors : string list; (** List of contributors for this entry *)
3436 repo_url : string option; (** Upstream repository URL *)
3537}
3636-(** A single day's changelog entry. *)
3838+(** A single day's changelog entry with hour tracking for real-time updates. *)
37393840type changes_file = {
3941 repository : string;
···7678val save : fs:_ Eio.Path.t -> monorepo:Fpath.t -> changes_file -> (unit, string) result
7779(** [save ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>.json. *)
78807979-val load_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> string -> (daily_changes_file, string) result
8080-(** [load_daily ~fs ~monorepo repo_name] loads daily changes from .changes/<repo_name>-daily.json.
8181- Returns an empty changes file if the file does not exist. *)
8181+val load_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> string -> (daily_changes_file, string) result
8282+(** [load_daily ~fs ~monorepo ~date repo_name] loads daily changes from .changes/<repo_name>-<date>.json.
8383+ Returns an empty changes file if the file does not exist.
8484+ @param date Date in YYYY-MM-DD format *)
82858383-val save_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> daily_changes_file -> (unit, string) result
8484-(** [save_daily ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>-daily.json. *)
8686+val save_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> daily_changes_file -> (unit, string) result
8787+(** [save_daily ~fs ~monorepo ~date cf] saves the changes file to .changes/<repo_name>-<date>.json.
8888+ @param date Date in YYYY-MM-DD format *)
85898690(** {1 Markdown Generation} *)
8791
+41-46
monopam/lib/monopam.ml
···1041104110421042 Log.info (fun m -> m "Processing %s" repo_name);
1043104310441044- (* Load existing daily changes from .changes/<repo>-daily.json *)
10451045- match Changes.load_daily ~fs:fs_t ~monorepo repo_name with
10461046- | Error e -> Error (Claude_error e)
10471047- | Ok changes_file ->
10481048- (* Process each day *)
10491049- let rec process_days day_offset updated_cf =
10501050- if day_offset >= days then Ok updated_cf
10511051- else begin
10521052- (* Calculate day boundaries *)
10531053- let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
10541054- let day_time = match Ptime.of_float_s (now -. offset_seconds) with
10551055- | Some t -> t
10561056- | None -> now_ptime
10571057- in
10581058- let date = Changes.date_of_ptime day_time in
10441044+ (* Process each day - with per-day files, we load/save per day *)
10451045+ let rec process_days day_offset =
10461046+ if day_offset >= days then Ok ()
10471047+ else begin
10481048+ (* Calculate day boundaries *)
10491049+ let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
10501050+ let day_time = match Ptime.of_float_s (now -. offset_seconds) with
10511051+ | Some t -> t
10521052+ | None -> now_ptime
10531053+ in
10541054+ let date = Changes.date_of_ptime day_time in
1059105510561056+ (* Load existing daily changes from .changes/<repo>-<date>.json *)
10571057+ match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
10581058+ | Error e -> Error (Claude_error e)
10591059+ | Ok changes_file ->
10601060 (* Skip if day already has an entry *)
10611061- if Changes.has_day updated_cf ~date then begin
10611061+ if Changes.has_day changes_file ~date then begin
10621062 Log.info (fun m -> m " Day %s already has entry, skipping" date);
10631063- process_days (day_offset + 1) updated_cf
10631063+ all_changes_files := changes_file :: !all_changes_files;
10641064+ process_days (day_offset + 1)
10641065 end
10651066 else begin
10661067 (* Get commits for this day *)
···10711072 | Ok commits ->
10721073 if commits = [] then begin
10731074 Log.info (fun m -> m " No commits for day %s" date);
10741074- process_days (day_offset + 1) updated_cf
10751075+ process_days (day_offset + 1)
10751076 end
10761077 else begin
10771078 Log.info (fun m -> m " Found %d commits for day %s" (List.length commits) date);
···10791080 if dry_run then begin
10801081 Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s on %s"
10811082 (List.length commits) repo_name date);
10821082- process_days (day_offset + 1) updated_cf
10831083+ process_days (day_offset + 1)
10831084 end
10841085 else begin
10851086 (* Analyze commits with Claude *)
···10891090 | Error e -> Error (Claude_error e)
10901091 | Ok None ->
10911092 Log.info (fun m -> m " No user-facing changes for day %s" date);
10921092- process_days (day_offset + 1) updated_cf
10931093+ process_days (day_offset + 1)
10931094 | Ok (Some response) ->
10941095 Log.app (fun m -> m " Generated changelog for %s on %s" repo_name date);
10951096 (* Extract unique contributors from commits *)
···11081109 else
11091110 Some url
11101111 in
11111111- (* Create new entry *)
11121112+ (* Create new entry with hour and timestamp *)
11121113 let first_hash = (List.hd commits).Git.hash in
11131114 let last_hash = (List.hd (List.rev commits)).Git.hash in
11151115+ let (_, ((hour, _, _), _)) = Ptime.to_date_time now_ptime in
11141116 let entry : Changes.daily_entry = {
11151117 date;
11181118+ hour;
11191119+ timestamp = now_ptime;
11161120 summary = response.Changes.summary;
11171121 changes = response.Changes.changes;
11181122 commit_range = {
···11231127 contributors;
11241128 repo_url;
11251129 } in
11261126- (* Add entry (sorted by date descending) *)
11301130+ (* Add entry (sorted by timestamp descending) *)
11271131 let new_entries =
11281128- entry :: updated_cf.Changes.entries
11321132+ entry :: changes_file.Changes.entries
11291133 |> List.sort (fun e1 e2 ->
11301130- String.compare e2.Changes.date e1.Changes.date)
11341134+ Ptime.compare e2.Changes.timestamp e1.Changes.timestamp)
11311135 in
11321132- process_days (day_offset + 1)
11331133- { updated_cf with entries = new_entries }
11361136+ let updated_cf = { changes_file with Changes.entries = new_entries } in
11371137+ (* Save the per-day file *)
11381138+ match Changes.save_daily ~fs:fs_t ~monorepo ~date updated_cf with
11391139+ | Error e -> Error (Claude_error e)
11401140+ | Ok () ->
11411141+ Log.app (fun m -> m "Saved .changes/%s-%s.json" repo_name date);
11421142+ all_changes_files := updated_cf :: !all_changes_files;
11431143+ process_days (day_offset + 1)
11341144 end
11351145 end
11361146 end
11371137- end
11381138- in
11391139- match process_days 0 changes_file with
11401140- | Error e -> Error e
11411141- | Ok updated_cf ->
11421142- (* Save if changed and not dry run *)
11431143- let save_result =
11441144- if not dry_run && updated_cf.entries <> changes_file.entries then
11451145- match Changes.save_daily ~fs:fs_t ~monorepo updated_cf with
11461146- | Error e -> Error (Claude_error e)
11471147- | Ok () ->
11481148- Log.app (fun m -> m "Saved .changes/%s-daily.json" repo_name);
11491149- Ok ()
11501150- else Ok ()
11511151- in
11521152- match save_result with
11531153- | Error e -> Error e
11541154- | Ok () ->
11551155- all_changes_files := updated_cf :: !all_changes_files;
11561156- process_repos rest
11471147+ end
11481148+ in
11491149+ match process_days 0 with
11501150+ | Error e -> Error e
11511151+ | Ok () -> process_repos rest
11571152 in
11581153 match process_repos repos with
11591154 | Error e -> Error e
+20-14
monopam/lib_changes/aggregated.ml
···35353636type entry = {
3737 repository : string;
3838+ hour : int;
3939+ timestamp : Ptime.t;
3840 summary : string;
3941 changes : string list;
4042 commit_range : commit_range;
···7173 |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count)
7274 |> Jsont.Object.finish
73757676+let ptime_jsont =
7777+ let enc t =
7878+ Ptime.to_rfc3339 t ~tz_offset_s:0
7979+ in
8080+ let dec s =
8181+ match Ptime.of_rfc3339 s with
8282+ | Ok (t, _, _) -> t
8383+ | Error _ -> failwith ("Invalid timestamp: " ^ s)
8484+ in
8585+ Jsont.map ~dec ~enc Jsont.string
8686+7487let entry_jsont =
7575- let make repository summary changes commit_range contributors repo_url change_type =
7676- { repository; summary; changes; commit_range; contributors; repo_url; change_type }
8888+ let make repository hour timestamp summary changes commit_range contributors repo_url change_type =
8989+ { repository; hour; timestamp; summary; changes; commit_range; contributors; repo_url; change_type }
7790 in
9191+ (* Default hour and timestamp for backwards compat when reading old files *)
9292+ let default_hour = 0 in
9393+ let default_timestamp = Ptime.epoch in
7894 Jsont.Object.map ~kind:"aggregated_entry" make
7995 |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun e -> e.repository)
9696+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour)
9797+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp)
8098 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary)
8199 |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes)
82100 |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range)
···84102 |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url)
85103 |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown ~enc:(fun e -> e.change_type)
86104 |> Jsont.Object.finish
8787-8888-let ptime_jsont =
8989- let enc t =
9090- match Ptime.to_rfc3339 t ~tz_offset_s:0 with
9191- | s -> s
9292- in
9393- let dec s =
9494- match Ptime.of_rfc3339 s with
9595- | Ok (t, _, _) -> t
9696- | Error _ -> failwith ("Invalid timestamp: " ^ s)
9797- in
9898- Jsont.map ~dec ~enc Jsont.string
99105100106let jsont =
101107 let make date generated_at git_head entries authors =
+2
monopam/lib_changes/aggregated.mli
···3636(** A single repository's changes for the day. *)
3737type entry = {
3838 repository : string; (** Repository name *)
3939+ hour : int; (** Hour of day 0-23 for filtering *)
4040+ timestamp : Ptime.t; (** RFC3339 timestamp for precise ordering *)
3941 summary : string; (** One-line summary of changes *)
4042 changes : string list; (** List of change bullet points *)
4143 commit_range : commit_range; (** Commits included *)
+246
monopam/lib_changes/daily.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type commit_range = {
77+ from_hash : string;
88+ to_hash : string;
99+ count : int;
1010+}
1111+1212+type entry = {
1313+ repository : string;
1414+ hour : int;
1515+ timestamp : Ptime.t;
1616+ summary : string;
1717+ changes : string list;
1818+ commit_range : commit_range;
1919+ contributors : string list;
2020+ repo_url : string option;
2121+}
2222+2323+type day = {
2424+ repository : string;
2525+ date : string;
2626+ entries : entry list;
2727+}
2828+2929+module String_map = Map.Make(String)
3030+3131+type t = {
3232+ by_repo : day list String_map.t;
3333+ by_date : day list String_map.t;
3434+ all_entries : entry list;
3535+}
3636+3737+(* JSON codecs for the per-day file format *)
3838+3939+let commit_range_jsont =
4040+ let make from_hash to_hash count = { from_hash; to_hash; count } in
4141+ Jsont.Object.map ~kind:"commit_range" make
4242+ |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash)
4343+ |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash)
4444+ |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count)
4545+ |> Jsont.Object.finish
4646+4747+let ptime_jsont =
4848+ let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in
4949+ let dec s =
5050+ match Ptime.of_rfc3339 s with
5151+ | Ok (t, _, _) -> t
5252+ | Error _ -> failwith ("Invalid timestamp: " ^ s)
5353+ in
5454+ Jsont.map ~dec ~enc Jsont.string
5555+5656+(* Entry codec for the file format (without repository, added during load) *)
5757+type file_entry = {
5858+ hour : int;
5959+ timestamp : Ptime.t;
6060+ summary : string;
6161+ changes : string list;
6262+ commit_range : commit_range;
6363+ contributors : string list;
6464+ repo_url : string option;
6565+}
6666+6767+let file_entry_jsont =
6868+ let make hour timestamp summary changes commit_range contributors repo_url =
6969+ { hour; timestamp; summary; changes; commit_range; contributors; repo_url }
7070+ in
7171+ let default_hour = 0 in
7272+ let default_timestamp = Ptime.epoch in
7373+ Jsont.Object.map ~kind:"daily_entry" make
7474+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour)
7575+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp)
7676+ |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary)
7777+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes)
7878+ |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range)
7979+ |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors)
8080+ |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url)
8181+ |> Jsont.Object.finish
8282+8383+type json_file = {
8484+ json_repository : string;
8585+ json_entries : file_entry list;
8686+}
8787+8888+let json_file_jsont =
8989+ let make json_repository json_entries = { json_repository; json_entries } in
9090+ Jsont.Object.map ~kind:"daily_changes_file" make
9191+ |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun f -> f.json_repository)
9292+ |> Jsont.Object.mem "entries" (Jsont.list file_entry_jsont) ~enc:(fun f -> f.json_entries)
9393+ |> Jsont.Object.finish
9494+9595+(* Parse date from filename: <repo>-<YYYY-MM-DD>.json *)
9696+let parse_daily_filename filename =
9797+ (* Check for pattern: ends with -YYYY-MM-DD.json *)
9898+ let len = String.length filename in
9999+ if len < 16 || not (String.ends_with ~suffix:".json" filename) then
100100+ None
101101+ else
102102+ (* Try to extract date: last 15 chars are -YYYY-MM-DD.json *)
103103+ let date_start = len - 15 in
104104+ let potential_date = String.sub filename (date_start + 1) 10 in
105105+ (* Validate date format YYYY-MM-DD *)
106106+ if String.length potential_date = 10 &&
107107+ potential_date.[4] = '-' && potential_date.[7] = '-' then
108108+ let repo = String.sub filename 0 date_start in
109109+ Some (repo, potential_date)
110110+ else
111111+ None
112112+113113+(* Load a single daily file *)
114114+let load_file ~fs ~changes_dir ~repo ~date : entry list =
115115+ let filename = repo ^ "-" ^ date ^ ".json" in
116116+ let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in
117117+ match Eio.Path.kind ~follow:true file_path with
118118+ | `Regular_file -> (
119119+ let content = Eio.Path.load file_path in
120120+ match Jsont_bytesrw.decode_string json_file_jsont content with
121121+ | Ok jf ->
122122+ List.map (fun (fe : file_entry) : entry ->
123123+ { repository = repo;
124124+ hour = fe.hour;
125125+ timestamp = fe.timestamp;
126126+ summary = fe.summary;
127127+ changes = fe.changes;
128128+ commit_range = fe.commit_range;
129129+ contributors = fe.contributors;
130130+ repo_url = fe.repo_url;
131131+ }) jf.json_entries
132132+ | Error _ -> [])
133133+ | _ -> []
134134+ | exception Eio.Io _ -> []
135135+136136+let empty = {
137137+ by_repo = String_map.empty;
138138+ by_date = String_map.empty;
139139+ all_entries = [];
140140+}
141141+142142+let list_repos ~fs ~changes_dir =
143143+ let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
144144+ match Eio.Path.kind ~follow:true dir_path with
145145+ | `Directory ->
146146+ let files = Eio.Path.read_dir dir_path in
147147+ files
148148+ |> List.filter_map parse_daily_filename
149149+ |> List.map fst
150150+ |> List.sort_uniq String.compare
151151+ | _ -> []
152152+ | exception Eio.Io _ -> []
153153+154154+let list_dates ~fs ~changes_dir ~repo =
155155+ let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
156156+ match Eio.Path.kind ~follow:true dir_path with
157157+ | `Directory ->
158158+ let files = Eio.Path.read_dir dir_path in
159159+ files
160160+ |> List.filter_map (fun filename ->
161161+ match parse_daily_filename filename with
162162+ | Some (r, date) when r = repo -> Some date
163163+ | _ -> None)
164164+ |> List.sort (fun a b -> String.compare b a) (* descending *)
165165+ | _ -> []
166166+ | exception Eio.Io _ -> []
167167+168168+let load_repo_day ~fs ~changes_dir ~repo ~date =
169169+ load_file ~fs ~changes_dir ~repo ~date
170170+171171+let load_repo_all ~fs ~changes_dir ~repo =
172172+ let dates = list_dates ~fs ~changes_dir ~repo in
173173+ List.concat_map (fun date -> load_file ~fs ~changes_dir ~repo ~date) dates
174174+175175+let load_all ~fs ~changes_dir =
176176+ let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
177177+ match Eio.Path.kind ~follow:true dir_path with
178178+ | `Directory ->
179179+ let files = Eio.Path.read_dir dir_path in
180180+ let parsed_files = List.filter_map parse_daily_filename files in
181181+182182+ (* Load all files and build days *)
183183+ let days : day list = List.filter_map (fun (repo, date) ->
184184+ let loaded_entries : entry list = load_file ~fs ~changes_dir ~repo ~date in
185185+ if loaded_entries = [] then None
186186+ else
187187+ let sorted_entries : entry list = List.sort (fun (e1 : entry) (e2 : entry) ->
188188+ Ptime.compare e1.timestamp e2.timestamp) loaded_entries
189189+ in
190190+ Some ({ repository = repo; date; entries = sorted_entries } : day)
191191+ ) parsed_files in
192192+193193+ (* Build by_repo map *)
194194+ let by_repo : day list String_map.t = List.fold_left (fun acc (d : day) ->
195195+ let existing = String_map.find_opt d.repository acc |> Option.value ~default:[] in
196196+ String_map.add d.repository (d :: existing) acc
197197+ ) String_map.empty days in
198198+199199+ (* Sort each repo's days by date descending *)
200200+ let by_repo : day list String_map.t = String_map.map (fun (ds : day list) ->
201201+ List.sort (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date) ds
202202+ ) by_repo in
203203+204204+ (* Build by_date map *)
205205+ let by_date : day list String_map.t = List.fold_left (fun acc (d : day) ->
206206+ let existing = String_map.find_opt d.date acc |> Option.value ~default:[] in
207207+ String_map.add d.date (d :: existing) acc
208208+ ) String_map.empty days in
209209+210210+ (* Sort each date's days by repo name *)
211211+ let by_date : day list String_map.t = String_map.map (fun (ds : day list) ->
212212+ List.sort (fun (d1 : day) (d2 : day) -> String.compare d1.repository d2.repository) ds
213213+ ) by_date in
214214+215215+ (* Collect all entries sorted by timestamp *)
216216+ let all_entries : entry list =
217217+ days
218218+ |> List.concat_map (fun (d : day) -> d.entries)
219219+ |> List.sort (fun (e1 : entry) (e2 : entry) -> Ptime.compare e1.timestamp e2.timestamp)
220220+ in
221221+222222+ { by_repo; by_date; all_entries }
223223+224224+ | _ -> empty
225225+ | exception Eio.Io _ -> empty
226226+227227+let since (t : t) (timestamp : Ptime.t) : entry list =
228228+ List.filter (fun (e : entry) -> Ptime.compare e.timestamp timestamp > 0) t.all_entries
229229+230230+let for_repo t repo =
231231+ String_map.find_opt repo t.by_repo |> Option.value ~default:[]
232232+233233+let for_date t date =
234234+ String_map.find_opt date t.by_date |> Option.value ~default:[]
235235+236236+let repos t =
237237+ String_map.bindings t.by_repo |> List.map fst
238238+239239+let dates t =
240240+ String_map.bindings t.by_date
241241+ |> List.map fst
242242+ |> List.sort (fun a b -> String.compare b a) (* descending *)
243243+244244+let entries_since ~fs ~changes_dir ~since:timestamp =
245245+ let t = load_all ~fs ~changes_dir in
246246+ since t timestamp
+117
monopam/lib_changes/daily.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Daily changes with per-day-per-repo structure.
77+88+ This module provides an immutable data structure for loading and querying
99+ daily changes from per-day-per-repo JSON files. Files are named
1010+ [<repo>-<YYYY-MM-DD>.json] and contain timestamped entries for real-time
1111+ tracking. *)
1212+1313+(** {1 Types} *)
1414+1515+type commit_range = {
1616+ from_hash : string;
1717+ to_hash : string;
1818+ count : int;
1919+}
2020+(** Commit range information. *)
2121+2222+type entry = {
2323+ repository : string;
2424+ hour : int;
2525+ timestamp : Ptime.t;
2626+ summary : string;
2727+ changes : string list;
2828+ commit_range : commit_range;
2929+ contributors : string list;
3030+ repo_url : string option;
3131+}
3232+(** A single timestamped changelog entry. *)
3333+3434+type day = {
3535+ repository : string;
3636+ date : string;
3737+ entries : entry list; (** Sorted by timestamp ascending. *)
3838+}
3939+(** All entries for a single repository on a single day. *)
4040+4141+module String_map : Map.S with type key = string
4242+(** String-keyed map type. *)
4343+4444+type t = {
4545+ by_repo : day list String_map.t;
4646+ (** Map from repository name to list of days. *)
4747+ by_date : day list String_map.t;
4848+ (** Map from date (YYYY-MM-DD) to list of days across repos. *)
4949+ all_entries : entry list;
5050+ (** All entries sorted by timestamp ascending. *)
5151+}
5252+(** Immutable collection of all loaded daily changes. *)
5353+5454+(** {1 Construction} *)
5555+5656+val empty : t
5757+(** Empty daily changes structure. *)
5858+5959+val load_all : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> t
6060+(** [load_all ~fs ~changes_dir] loads all [<repo>-<YYYY-MM-DD>.json] files
6161+ from the changes directory and returns an immutable structure for querying. *)
6262+6363+(** {1 Querying} *)
6464+6565+val since : t -> Ptime.t -> entry list
6666+(** [since t timestamp] returns all entries with timestamp after [timestamp],
6767+ sorted by timestamp ascending. *)
6868+6969+val for_repo : t -> string -> day list
7070+(** [for_repo t repo] returns all days for the given repository,
7171+ sorted by date descending. *)
7272+7373+val for_date : t -> string -> day list
7474+(** [for_date t date] returns all days (across repos) for the given date. *)
7575+7676+val repos : t -> string list
7777+(** [repos t] returns list of all repository names with changes. *)
7878+7979+val dates : t -> string list
8080+(** [dates t] returns list of all dates with changes, sorted descending. *)
8181+8282+(** {1 File Discovery} *)
8383+8484+val list_repos : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> string list
8585+(** [list_repos ~fs ~changes_dir] returns all repository names that have
8686+ daily change files. *)
8787+8888+val list_dates : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> repo:string -> string list
8989+(** [list_dates ~fs ~changes_dir ~repo] returns all dates for which the given
9090+ repository has change files. *)
9191+9292+(** {1 Loading Individual Files} *)
9393+9494+val load_repo_day :
9595+ fs:_ Eio.Path.t ->
9696+ changes_dir:Fpath.t ->
9797+ repo:string ->
9898+ date:string ->
9999+ entry list
100100+(** [load_repo_day ~fs ~changes_dir ~repo ~date] loads entries for a specific
101101+ repo and date. Returns empty list if file doesn't exist. *)
102102+103103+val load_repo_all :
104104+ fs:_ Eio.Path.t ->
105105+ changes_dir:Fpath.t ->
106106+ repo:string ->
107107+ entry list
108108+(** [load_repo_all ~fs ~changes_dir ~repo] loads all entries for a repository
109109+ across all dates. *)
110110+111111+val entries_since :
112112+ fs:_ Eio.Path.t ->
113113+ changes_dir:Fpath.t ->
114114+ since:Ptime.t ->
115115+ entry list
116116+(** [entries_since ~fs ~changes_dir ~since] returns all entries created after
117117+ the given timestamp, useful for real-time updates. *)
+5-1
monopam/lib_changes/monopam_changes.ml
···66(** Library for parsing and querying aggregated daily changes.
7788 This library provides types and functions for working with the aggregated
99- daily changes format used by the monopam tool and the poe Zulip bot. *)
99+ daily changes format used by the monopam tool and the poe Zulip bot.
1010+1111+ The {!Daily} module provides an immutable data structure for loading
1212+ per-day-per-repo JSON files ([<repo>-<YYYY-MM-DD>.json]). *)
10131114module Aggregated = Aggregated
1515+module Daily = Daily
1216module Query = Query
+51
monopam/lib_changes/query.ml
···8787 count (if count = 1 then "" else "s")
8888 (List.length repos) (if List.length repos = 1 then "y" else "ies")
8989 (String.concat ", " repos)
9090+9191+(** {1 Daily Changes (Real-time)} *)
9292+9393+let daily_changes_since ~fs ~changes_dir ~since =
9494+ Daily.entries_since ~fs ~changes_dir ~since
9595+9696+let has_new_daily_changes ~fs ~changes_dir ~since =
9797+ daily_changes_since ~fs ~changes_dir ~since <> []
9898+9999+let format_daily_for_zulip ~entries ~include_date ~date =
100100+ if entries = [] then
101101+ "No changes to report."
102102+ else begin
103103+ let buf = Buffer.create 1024 in
104104+ if include_date then begin
105105+ match date with
106106+ | Some d -> Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d)
107107+ | None -> Buffer.add_string buf "## Recent Changes\n\n"
108108+ end;
109109+ (* Group by repository *)
110110+ let repos = List.sort_uniq String.compare
111111+ (List.map (fun (e : Daily.entry) -> e.repository) entries) in
112112+ List.iter (fun repo ->
113113+ let repo_entries = List.filter (fun (e : Daily.entry) -> e.repository = repo) entries in
114114+ if repo_entries <> [] then begin
115115+ let first_entry = List.hd repo_entries in
116116+ let repo_link = format_repo_link repo first_entry.repo_url in
117117+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_link);
118118+ List.iter (fun (entry : Daily.entry) ->
119119+ Buffer.add_string buf (Printf.sprintf "**%s**\n" entry.summary);
120120+ List.iter (fun change ->
121121+ Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes;
122122+ if entry.contributors <> [] then
123123+ Buffer.add_string buf (Printf.sprintf "*Contributors: %s*\n"
124124+ (String.concat ", " entry.contributors));
125125+ Buffer.add_string buf "\n") repo_entries
126126+ end) repos;
127127+ Buffer.contents buf
128128+ end
129129+130130+let format_daily_summary ~entries =
131131+ if entries = [] then
132132+ "No new changes."
133133+ else
134134+ let count = List.length entries in
135135+ let repos = List.sort_uniq String.compare
136136+ (List.map (fun (e : Daily.entry) -> e.repository) entries) in
137137+ Printf.sprintf "%d change%s across %d repositor%s: %s"
138138+ count (if count = 1 then "" else "s")
139139+ (List.length repos) (if List.length repos = 1 then "y" else "ies")
140140+ (String.concat ", " repos)
+30
monopam/lib_changes/query.mli
···4040 entries:Aggregated.entry list ->
4141 string
4242(** Format a brief summary of the changes. *)
4343+4444+(** {1 Daily Changes (Real-time)} *)
4545+4646+val daily_changes_since :
4747+ fs:_ Eio.Path.t ->
4848+ changes_dir:Fpath.t ->
4949+ since:Ptime.t ->
5050+ Daily.entry list
5151+(** Get all daily change entries created after [since] timestamp.
5252+ Uses the per-day-per-repo files for real-time access. *)
5353+5454+val has_new_daily_changes :
5555+ fs:_ Eio.Path.t ->
5656+ changes_dir:Fpath.t ->
5757+ since:Ptime.t ->
5858+ bool
5959+(** Check if there are any new daily changes since the given timestamp. *)
6060+6161+val format_daily_for_zulip :
6262+ entries:Daily.entry list ->
6363+ include_date:bool ->
6464+ date:string option ->
6565+ string
6666+(** Format daily entries as markdown suitable for Zulip.
6767+ Groups entries by repository. *)
6868+6969+val format_daily_summary :
7070+ entries:Daily.entry list ->
7171+ string
7272+(** Format a brief summary of daily changes. *)
+14-2
ocaml-zulip/lib/zulip_bot/storage.ml
···9090 Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
9191 None
9292 with Eio.Exn.Io (e, _) ->
9393- Log.warn (fun m -> m "Error fetching key %s: %a" key Eio.Exn.pp_err e);
9494- None)
9393+ let is_key_not_found = match e with
9494+ | Zulip.Error.E err ->
9595+ Zulip.Error.code err = Zulip.Error.Bad_request &&
9696+ String.equal (Zulip.Error.message err) "Key does not exist."
9797+ | _ -> false
9898+ in
9999+ if is_key_not_found then begin
100100+ (* Key not found is a normal case, not an error *)
101101+ Log.debug (fun m -> m "Key not found in storage: %s" key);
102102+ None
103103+ end else begin
104104+ Log.warn (fun m -> m "Error fetching key %s: %a" key Eio.Exn.pp_err e);
105105+ None
106106+ end)
9510796108let set t key value =
97109 Log.debug (fun m -> m "Storing key: %s" key);
+16-8
poe/bin/main.ml
···1616 let clock = Eio.Stdenv.clock env in
17171818 (* Load poe config: explicit path > XDG > current dir > defaults *)
1919- let poe_config =
1919+ let poe_config, config_source =
2020 match config_file with
2121- | Some path -> Poe.Config.load ~fs path
2121+ | Some path -> (Poe.Config.load ~fs path, Printf.sprintf "explicit path: %s" path)
2222 | None -> (
2323 match Poe.Config.load_xdg_opt ~fs with
2424- | Some c -> c
2424+ | Some c -> (c, "XDG config (~/.config/poe/config.toml)")
2525 | None -> (
2626 match Poe.Config.load_opt ~fs "poe.toml" with
2727- | Some c -> c
2828- | None -> Poe.Config.default))
2727+ | Some c -> (c, "current directory (poe.toml)")
2828+ | None -> (Poe.Config.default, "built-in defaults")))
2929 in
30303131+ Logs.info (fun m -> m "Poe config loaded from %s" config_source);
3232+ Logs.info (fun m -> m " Channel: %s, Topic: %s" poe_config.channel poe_config.topic);
3333+ Logs.info (fun m -> m " Monorepo: %s, Changes dir: %s" poe_config.monorepo_path poe_config.changes_dir);
3434+ let admin_count = List.length poe_config.admin_emails in
3535+ if admin_count > 0 then
3636+ Logs.info (fun m -> m " Admin users: %d configured (%s)" admin_count
3737+ (String.concat ", " poe_config.admin_emails))
3838+ else
3939+ Logs.info (fun m -> m " Admin users: none configured");
4040+3141 (* Load zulip bot config from poe's XDG directory *)
3242 let zulip_config = Zulip_bot.Config.load_or_env ~xdg_app:"poe" ~fs bot_name in
3343···38483949 (* Create and run the bot *)
4050 let handler = Poe.Handler.make_handler handler_env poe_config in
4141- Logs.info (fun m ->
4242- m "Starting Poe bot, broadcasting to %s/%s" poe_config.channel
4343- poe_config.topic);
5151+ Logs.info (fun m -> m "Starting Poe bot...");
4452 Zulip_bot.Bot.run ~sw ~env ~config:zulip_config ~handler
45534654let run_cmd =