elfeed-custom-feeds.el

Custom feed generators (GitHub PR/issues and HuggingFace model releases) for elfeed.
Log | Files | Refs | LICENSE

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 "<" "&lt;"
    110                                                                           (replace-regexp-in-string ">" "&gt;" 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