A Zulip bot agent to sit in our Black Sun. Ever evolving

Use dev-repo URLs from opam metadata for changelog package links

Instead of linking packages to the opam overlay (opamrepo_url/packages/name),
scan the local opam repo to extract the dev-repo field from each package's
opam file and link to the actual project repository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+84 -40
+1 -1
bin/main.ml
··· 148 148 let members = Poe.Changelog.get_channel_members ~client ~channel:poe_config.channel in 149 149 150 150 (* Generate narrative changelog with Claude *) 151 - match Poe.Changelog.generate ~sw ~proc ~clock ~commits ~members () with 151 + match Poe.Changelog.generate ~sw ~proc ~clock ~fs ~commits ~members () with 152 152 | None -> 153 153 Logs.err (fun m -> m "Could not generate changelog") 154 154 | Some content ->
+1 -1
lib/broadcast.ml
··· 40 40 let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 41 41 42 42 (* Generate narrative changelog with Claude *) 43 - match Changelog.generate ~sw ~proc ~clock ~commits ~members () with 43 + match Changelog.generate ~sw ~proc ~clock ~fs ~commits ~members () with 44 44 | None -> 45 45 Zulip_bot.Response.reply "Could not generate changelog." 46 46 | Some content ->
+30 -6
lib/changelog.ml
··· 189 189 Log.warn (fun m -> m "Failed to parse JSON response: %s" e); 190 190 Error e 191 191 192 + (* Build a map from package name to dev-repo URL by scanning the opam repo *) 193 + let build_package_url_map ~fs ~opamrepo_path = 194 + match opamrepo_path with 195 + | None -> Hashtbl.create 0 196 + | Some path -> 197 + let map = Hashtbl.create 16 in 198 + let packages, _errors = Monopam.Opam_repo.scan_all ~fs path in 199 + List.iter (fun pkg -> 200 + let name = Monopam.Package.name pkg in 201 + let dev_repo = Monopam.Package.dev_repo pkg in 202 + (* Convert git URL to browsable HTTPS URL *) 203 + let url = Uri.to_string dev_repo in 204 + (* Remove trailing .git if present *) 205 + let url = if String.ends_with ~suffix:".git" url 206 + then String.sub url 0 (String.length url - 4) 207 + else url 208 + in 209 + Hashtbl.replace map name url 210 + ) packages; 211 + map 212 + 192 213 (* Format a changelog item as a markdown bullet with repo link *) 193 - let format_item ~opamrepo_url item = 194 - let project_link = match opamrepo_url with 214 + let format_item ~package_urls item = 215 + let project_link = match Hashtbl.find_opt package_urls item.project with 195 216 | Some url -> 196 - (* Link to the package in the opam repo: url/packages/project *) 197 - Printf.sprintf "[%s](%s/packages/%s)" item.project url item.project 217 + (* Link to the actual project repository *) 218 + Printf.sprintf "[%s](%s)" item.project url 198 219 | None -> item.project 199 220 in 200 221 Printf.sprintf "- **%s**: %s *%s*" project_link item.description item.change_type 201 222 202 - let generate ~sw ~proc ~clock ~commits ~members ?opamrepo_url () = 223 + let generate ~sw ~proc ~clock ~fs ~commits ~members ?opamrepo_path () = 203 224 if commits = [] then None 204 225 else begin 205 226 Log.info (fun m -> m "Generating narrative changelog with Claude for %d commits" (List.length commits)); 227 + 228 + (* Build package URL map from opam repo *) 229 + let package_urls = build_package_url_map ~fs ~opamrepo_path in 206 230 207 231 (* Get affected sub-projects *) 208 232 let subprojects = affected_subprojects commits in ··· 275 299 | None -> "" 276 300 in 277 301 let formatted = items 278 - |> List.map (format_item ~opamrepo_url) 302 + |> List.map (format_item ~package_urls) 279 303 |> String.concat "\n" 280 304 in 281 305 Some (header ^ formatted)
+7 -5
lib/changelog.mli
··· 59 59 sw:Eio.Switch.t -> 60 60 proc:_ Eio.Process.mgr -> 61 61 clock:float Eio.Time.clock_ty Eio.Resource.t -> 62 + fs:_ Eio.Path.t -> 62 63 commits:commit list -> 63 64 members:channel_member list -> 64 - ?opamrepo_url:string -> 65 + ?opamrepo_path:Fpath.t -> 65 66 unit -> 66 67 string option 67 - (** [generate ~sw ~proc ~clock ~commits ~members ?opamrepo_url ()] generates a 68 - bullet-point changelog using Claude. Returns [None] if commits is empty, 68 + (** [generate ~sw ~proc ~clock ~fs ~commits ~members ?opamrepo_path ()] generates 69 + a bullet-point changelog using Claude. Returns [None] if commits is empty, 69 70 or [Some changelog] with the generated text. 70 71 71 - Each bullet has the project name (linked to the opam repo if [opamrepo_url] 72 - is provided), a description of the change, and the change type in italics. 72 + Each bullet has the project name (linked to the actual project repository 73 + from the opam metadata's dev-repo field if [opamrepo_path] is provided), 74 + a description of the change, and the change type in italics. 73 75 The output includes a header with the date of the most recent commit. 74 76 Zulip @-mentions are used for authors matching channel members. *)
+1 -1
lib/handler.ml
··· 196 196 let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 197 197 198 198 (* Generate narrative changelog with Claude *) 199 - match Changelog.generate ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock ~commits ~members () with 199 + match Changelog.generate ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock ~fs:env.fs ~commits ~members () with 200 200 | None -> 201 201 Zulip_bot.Response.reply 202 202 (Printf.sprintf "**Refresh completed:**\n\n- %s\n- Could not generate changelog" pull_msg)
+44 -26
lib/loop.ml
··· 12 12 monorepo_path : Eio.Fs.dir_ty Eio.Path.t; 13 13 monorepo_url : string; 14 14 opamrepo_url : string; 15 + opamrepo_path : Fpath.t option; (* Local path to opam repo for reading dev-repo URLs *) 15 16 } 16 17 17 18 (* Load the opamverse registry from XDG data path *) ··· 52 53 with _ -> false 53 54 in 54 55 if is_repo then 56 + (* Check if local opam repo exists *) 57 + let opam_dir = handle ^ "-opam" in 58 + let opam_path = Eio.Path.(verse_eio / opam_dir) in 59 + let opamrepo_path = try 60 + match Eio.Path.kind ~follow:true opam_path with 61 + | `Directory -> Some (Fpath.v (verse_path ^ "/" ^ opam_dir)) 62 + | _ -> None 63 + with _ -> None 64 + in 55 65 Some { 56 66 handle; 57 67 monorepo_path = mono_path; 58 68 monorepo_url = member.monorepo; 59 69 opamrepo_url = member.opamrepo; 70 + opamrepo_path; 60 71 } 61 72 else begin 62 73 Log.debug (fun m -> m "Skipping %s: not a git repo" handle); ··· 91 102 | (0, stdout, _) -> Some (String.trim stdout) 92 103 | _ -> None 93 104 94 - let run_git_pull ~proc ~cwd = 95 - Log.info (fun m -> m "Pulling latest changes from remote"); 96 - match run_command ~proc ~cwd ["git"; "pull"; "--ff-only"] with 97 - | (0, stdout, _) -> 98 - let output = String.trim stdout in 99 - if output = "Already up to date." then 100 - Log.info (fun m -> m "Repository already up to date") 101 - else begin 102 - Log.info (fun m -> m "Pulled new changes from remote"); 103 - String.split_on_char '\n' output 104 - |> List.iter (fun line -> 105 - let line = String.trim line in 106 - if line <> "" then Log.info (fun m -> m " %s" line)) 107 - end; 108 - true 109 - | (code, _, stderr) when code > 0 -> 110 - Log.warn (fun m -> m "git pull exited with code %d: %s" code (String.trim stderr)); 111 - false 112 - | (sig_, _, _) -> 113 - Log.warn (fun m -> m "git pull killed by signal %d" (-sig_)); 105 + (* Sync a tracked repo by fetching and resetting to upstream. 106 + We don't make local commits to verse repos, so reset is safe. *) 107 + let sync_to_upstream ~proc ~cwd ~handle = 108 + Log.debug (fun m -> m "[%s] Fetching from origin" handle); 109 + match run_command ~proc ~cwd ["git"; "fetch"; "origin"] with 110 + | (0, _, _) -> 111 + (* Get the default branch name *) 112 + let branch = match run_command ~proc ~cwd 113 + ["git"; "rev-parse"; "--abbrev-ref"; "origin/HEAD"] with 114 + | (0, stdout, _) -> 115 + (* Returns "origin/main" or "origin/master" - extract branch name *) 116 + let full = String.trim stdout in 117 + (match String.split_on_char '/' full with 118 + | _ :: branch :: _ -> branch 119 + | _ -> "main") 120 + | _ -> "main" 121 + in 122 + Log.debug (fun m -> m "[%s] Resetting to origin/%s" handle branch); 123 + (match run_command ~proc ~cwd ["git"; "reset"; "--hard"; "origin/" ^ branch] with 124 + | (0, _, _) -> true 125 + | (code, _, stderr) -> 126 + Log.warn (fun m -> m "[%s] git reset failed (code %d): %s" 127 + handle code (String.trim stderr)); 128 + false) 129 + | (code, _, stderr) -> 130 + Log.warn (fun m -> m "[%s] git fetch failed (code %d): %s" 131 + handle code (String.trim stderr)); 114 132 false 115 133 116 134 let send_message ~client ~stream ~topic ~content = ··· 119 137 Log.info (fun m -> m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) 120 138 121 139 (* Process a single verse user: pull, check HEAD, generate changelog if needed *) 122 - let process_verse_user ~sw ~proc ~clock ~storage ~client ~config user = 140 + let process_verse_user ~sw ~proc ~clock ~fs ~storage ~client ~config user = 123 141 let handle = user.handle in 124 142 Log.info (fun m -> m "Checking %s for changes..." handle); 125 143 126 - (* Pull latest changes from remote *) 127 - let _pull_ok = run_git_pull ~proc ~cwd:user.monorepo_path in 144 + (* Sync to upstream (fetch + reset, since we don't make local commits here) *) 145 + let _sync_ok = sync_to_upstream ~proc ~cwd:user.monorepo_path ~handle in 128 146 129 147 (* Get current git HEAD *) 130 148 let current_head = get_git_head ~proc ~cwd:user.monorepo_path in ··· 160 178 let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 161 179 162 180 (* Generate narrative changelog with Claude *) 163 - match Changelog.generate ~sw ~proc ~clock ~commits ~members 164 - ~opamrepo_url:user.opamrepo_url () with 181 + match Changelog.generate ~sw ~proc ~clock ~fs ~commits ~members 182 + ?opamrepo_path:user.opamrepo_path () with 165 183 | None -> 166 184 Log.info (fun m -> m "[%s] No changelog generated" handle); 167 185 Option.iter (Admin.set_user_git_head storage ~handle) current_head ··· 223 241 (* Process each user *) 224 242 List.iter (fun user -> 225 243 try 226 - process_verse_user ~sw ~proc ~clock ~storage ~client ~config user 244 + process_verse_user ~sw ~proc ~clock ~fs ~storage ~client ~config user 227 245 with e -> 228 246 Log.warn (fun m -> m "[%s] Error processing user: %s" 229 247 user.handle (Printexc.to_string e))