elfeed-custom-feeds-github.el (10465B)
1 ;;; elfeed-custom-feeds-github.el --- GitHub PR feed generator for Elfeed -*- lexical-binding: t; -*- 2 3 ;; Copyright (C) 2025 4 5 ;; SPDX-License-Identifier: GPL-3.0-or-later 6 7 ;;; Commentary: 8 9 ;; GitHub pull request feed generator for Elfeed with custom queries. 10 11 ;;; Code: 12 13 (require 'elfeed) 14 (require 'elfeed-curl) 15 (require 'json) 16 (require 'cl-lib) 17 18 (defgroup elfeed-custom-feeds-github nil 19 "GitHub PR feed generator for Elfeed." 20 :group 'elfeed 21 :prefix "elfeed-custom-github-") 22 23 (defcustom elfeed-custom-github-pr-queries 24 '(("emacs-mirror/emacs" "" "Emacs") 25 ("atlas-engineer/nyxt" "is:open" "Nyxt") 26 ("qutebrowser/qutebrowser" "label:\"bug: behavior\"" "qutebrowser behavior bug")) 27 "List of (repo query display-name) tuples for GitHub PR monitoring." 28 :type '(repeat (list (string :tag "Repository") 29 (string :tag "Query") 30 (string :tag "Display Name"))) 31 :group 'elfeed-custom-feeds-github) 32 33 (defcustom elfeed-custom-github-request-delay 1.0 34 "Delay in seconds between GitHub API requests to avoid rate limiting." 35 :type 'number 36 :group 'elfeed-custom-feeds-github) 37 38 (defun elfeed-custom-feeds-github--fetch-prs (repo query display-name callback delay) 39 "Fetch PRs for REPO matching QUERY after DELAY seconds and call CALLBACK with results." 40 (let ((url (format "https://api.github.com/search/issues?q=repo:%s+is:pr+%s&sort=created&order=desc&per_page=20" 41 repo (url-hexify-string query))) 42 (cb callback) 43 (r repo) 44 (dn display-name)) 45 (run-at-time delay nil 46 (lambda () 47 (elfeed-curl-enqueue url 48 (lambda (status) 49 (if (not status) 50 (message "Error fetching GitHub PRs %s [%s]: %s" r dn elfeed-curl-error-message) 51 ;; Capture JSON text for async parsing 52 (let ((json-text (buffer-substring-no-properties (point-min) (point-max)))) 53 ;; Parse JSON in idle timer to avoid blocking 54 (run-with-idle-timer 0 nil 55 (lambda () 56 (condition-case err 57 (with-temp-buffer 58 (insert json-text) 59 (goto-char (point-min)) 60 (let* ((json-object-type 'alist) 61 (json-array-type 'list) 62 (response (json-read)) 63 (items (alist-get 'items response))) 64 (funcall cb r dn items))) 65 (error 66 (message "Error parsing JSON for GitHub %s [%s]: %s" r dn err)))))))) 67 :headers '(("Accept" . "application/vnd.github+json"))))))) 68 69 (defun elfeed-custom-feeds-github--prs-to-elfeed (repo display-name prs completion-callback) 70 "Convert GitHub PRS to elfeed entries for REPO with DISPLAY-NAME. 71 Call COMPLETION-CALLBACK with (repo display-name new-entries) when done." 72 (let ((feed-url (format "https://github.com/%s/pulls" repo)) 73 (new-entries ()) 74 (prs-to-process prs)) 75 ;; Set up feed metadata asynchronously 76 (run-with-idle-timer 0 nil 77 (lambda () 78 (let ((feed (elfeed-db-get-feed feed-url))) 79 (setf (elfeed-feed-url feed) feed-url 80 (elfeed-feed-title feed) (format "GitHub: %s [%s]" repo display-name))))) 81 82 ;; Process PRs in chunks to avoid blocking 83 (cl-labels ((process-next-chunk () 84 (when prs-to-process 85 ;; Process 5 PRs at a time 86 (let ((chunk (cl-subseq prs-to-process 0 (min 5 (length prs-to-process))))) 87 (setq prs-to-process (cl-subseq prs-to-process (length chunk))) 88 ;; Process this chunk 89 (dolist (pr chunk) 90 (let* ((number (alist-get 'number pr)) 91 (title-text (alist-get 'title pr)) 92 (html-url (alist-get 'html_url pr)) 93 (state (alist-get 'state pr)) 94 (updated (alist-get 'updated_at pr)) 95 (created (alist-get 'created_at pr)) 96 (user (alist-get 'login (alist-get 'user pr))) 97 (body (or (alist-get 'body pr) "No description provided.")) 98 (labels (mapcar (lambda (label) (alist-get 'name label)) 99 (alist-get 'labels pr))) 100 (entry-id html-url) 101 (title (format "#%d: %s" number title-text)) 102 (date (float-time (date-to-time updated))) 103 (content (format "<h2>PR #%d: %s</h2>\n<p><strong>State:</strong> %s</p>\n<p><strong>Author:</strong> %s</p>\n<p><strong>Created:</strong> %s</p>\n<p><strong>Updated:</strong> %s</p>\n%s\n<h3>Description:</h3>\n<pre>%s</pre>" 104 number title-text state user created updated 105 (if labels 106 (format "<p><strong>Labels:</strong> %s</p>" 107 (mapconcat 'identity labels ", ")) 108 "") 109 (replace-regexp-in-string "<" "<" 110 (replace-regexp-in-string ">" ">" body))))) 111 (unless (elfeed-db-get-entry entry-id) 112 (let ((entry (elfeed-entry--create 113 :id entry-id 114 :title title 115 :link html-url 116 :date date 117 :content content 118 :content-type 'html 119 :feed-id feed-url 120 :tags '(unread github)))) 121 (push entry new-entries))))) 122 ;; Schedule next chunk or call completion 123 (if prs-to-process 124 (run-with-idle-timer 0 nil #'process-next-chunk) 125 ;; All done - call completion callback 126 (funcall completion-callback repo display-name (nreverse new-entries))))))) 127 (process-next-chunk)))) 128 129 ;;;###autoload 130 (defun elfeed-custom-feeds-github-update () 131 "Update all GitHub PR feeds asynchronously. 132 Batches all entries and updates UI once at the end to avoid blocking." 133 (interactive) 134 (let* ((total (length elfeed-custom-github-pr-queries)) 135 (processed 0) 136 (all-new-entries '()) 137 (results '())) 138 139 (if (zerop total) 140 (message "No GitHub PR queries configured") 141 (let ((completion-callback 142 (lambda (repo display-name new-entries) 143 (run-with-idle-timer 0 nil 144 (lambda () 145 (setq processed (1+ processed)) 146 (when new-entries 147 (setq all-new-entries (append all-new-entries new-entries))) 148 (push (list repo display-name (length new-entries)) results) 149 ;; When all queries are done, batch add to DB and update UI once 150 (when (= processed total) 151 (let ((total-count (length all-new-entries))) 152 (when (> total-count 0) 153 (run-with-idle-timer 0 nil 154 (lambda () 155 (elfeed-db-add all-new-entries)))) 156 ;; Report results for each feed 157 (dolist (result (nreverse results)) 158 (message "GitHub feed %s [%s]: %d new entries" 159 (nth 0 result) (nth 1 result) (nth 2 result))) 160 ;; Defer UI update to avoid blocking 161 (run-with-idle-timer 0.2 nil 162 (lambda () 163 (when (get-buffer "*elfeed-search*") 164 (with-current-buffer "*elfeed-search*" 165 (elfeed-search-update--force)))))))))))) 166 ;; Start all async fetches 167 (cl-loop for (repo query display-name) in elfeed-custom-github-pr-queries 168 for i from 0 169 for delay = (* i elfeed-custom-github-request-delay) 170 do (elfeed-custom-feeds-github--fetch-prs 171 repo query display-name 172 (lambda (r dn items) 173 (elfeed-custom-feeds-github--prs-to-elfeed r dn items completion-callback)) 174 delay)))))) 175 176 (provide 'elfeed-custom-feeds-github) 177 178 ;;; elfeed-custom-feeds-github.el ends here