;;; elfeed-custom-feeds-github.el --- GitHub PR feed generator for Elfeed -*- lexical-binding: t; -*- ;; Copyright (C) 2025 ;; SPDX-License-Identifier: GPL-3.0-or-later ;;; Commentary: ;; GitHub pull request feed generator for Elfeed with custom queries. ;;; Code: (require 'elfeed) (require 'url) (require 'json) (require 'cl-lib) (defgroup elfeed-custom-feeds-github nil "GitHub PR feed generator for Elfeed." :group 'elfeed :prefix "elfeed-custom-github-") (defcustom elfeed-custom-github-pr-queries '(("emacs-mirror/emacs" "" "Emacs") ("atlas-engineer/nyxt" "is:open" "Nyxt") ("qutebrowser/qutebrowser" "label:\"bug: behavior\"" "qutebrowser behavior bug")) "List of (repo query display-name) tuples for GitHub PR monitoring." :type '(repeat (list (string :tag "Repository") (string :tag "Query") (string :tag "Display Name"))) :group 'elfeed-custom-feeds-github) (defcustom elfeed-custom-github-request-delay 1.0 "Delay in seconds between GitHub API requests to avoid rate limiting." :type 'number :group 'elfeed-custom-feeds-github) (defun elfeed-custom-feeds-github--fetch-prs (repo query display-name callback delay) "Fetch PRs for REPO matching QUERY after DELAY seconds and call CALLBACK with results." (let ((url (format "https://api.github.com/search/issues?q=repo:%s+is:pr+%s&sort=created&order=desc&per_page=20" repo (url-hexify-string query))) (cb callback) (r repo) (dn display-name)) (run-at-time delay nil (lambda () (url-retrieve url (lambda (status) (if (plist-get status :error) (message "Error fetching GitHub PRs %s [%s]: %s" r dn (plist-get status :error)) (goto-char (point-min)) (re-search-forward "\n\n") ;; Capture JSON text for async parsing (let ((json-text (buffer-substring-no-properties (point) (point-max)))) ;; Parse JSON in idle timer to avoid blocking (run-with-idle-timer 0 nil (lambda () (condition-case err (with-temp-buffer (insert json-text) (goto-char (point-min)) (let* ((json-object-type 'alist) (json-array-type 'list) (response (json-read)) (items (alist-get 'items response))) (funcall cb r dn items))) (error (message "Error parsing JSON for GitHub %s [%s]: %s" r dn err))))))) nil t))))) (defun elfeed-custom-feeds-github--prs-to-elfeed (repo display-name prs completion-callback) "Convert GitHub PRS to elfeed entries for REPO with DISPLAY-NAME. Call COMPLETION-CALLBACK with (repo display-name new-entries) when done." (let ((feed-url (format "https://github.com/%s/pulls" repo)) (new-entries ()) (prs-to-process prs)) ;; Set up feed metadata asynchronously (run-with-idle-timer 0 nil (lambda () (let ((feed (elfeed-db-get-feed feed-url))) (setf (elfeed-feed-url feed) feed-url (elfeed-feed-title feed) (format "GitHub: %s [%s]" repo display-name))))) ;; Process PRs in chunks to avoid blocking (cl-labels ((process-next-chunk () (when prs-to-process ;; Process 5 PRs at a time (let ((chunk (cl-subseq prs-to-process 0 (min 5 (length prs-to-process))))) (setq prs-to-process (cl-subseq prs-to-process (length chunk))) ;; Process this chunk (dolist (pr chunk) (let* ((number (alist-get 'number pr)) (title-text (alist-get 'title pr)) (html-url (alist-get 'html_url pr)) (state (alist-get 'state pr)) (updated (alist-get 'updated_at pr)) (created (alist-get 'created_at pr)) (user (alist-get 'login (alist-get 'user pr))) (body (or (alist-get 'body pr) "No description provided.")) (labels (mapcar (lambda (label) (alist-get 'name label)) (alist-get 'labels pr))) (entry-id html-url) (title (format "#%d: %s" number title-text)) (date (float-time (date-to-time updated))) (content (format "
State: %s
\nAuthor: %s
\nCreated: %s
\nUpdated: %s
\n%s\n%s" number title-text state user created updated (if labels (format "
Labels: %s
" (mapconcat 'identity labels ", ")) "") (replace-regexp-in-string "<" "<" (replace-regexp-in-string ">" ">" body))))) (unless (elfeed-db-get-entry entry-id) (let ((entry (elfeed-entry--create :id entry-id :title title :link html-url :date date :content content :content-type 'html :feed-id feed-url :tags '(unread github)))) (push entry new-entries))))) ;; Schedule next chunk or call completion (if prs-to-process (run-with-idle-timer 0 nil #'process-next-chunk) ;; All done - call completion callback (funcall completion-callback repo display-name (nreverse new-entries))))))) (process-next-chunk))))) ;;;###autoload (defun elfeed-custom-feeds-github-update () "Update all GitHub PR feeds asynchronously. Batches all entries and updates UI once at the end to avoid blocking." (interactive) (let* ((total (length elfeed-custom-github-pr-queries)) (processed 0) (all-new-entries '()) (results '())) (when (zerop total) (message "No GitHub PR queries configured") (cl-return-from elfeed-custom-feeds-github-update nil)) ;; Completion callback that batches everything (let ((completion-callback (lambda (repo display-name new-entries) (run-with-idle-timer 0 nil (lambda () (setq processed (1+ processed)) (when new-entries (setq all-new-entries (append all-new-entries new-entries))) (push (list repo display-name (length new-entries)) results) ;; When all queries are done, batch add to DB and update UI once (when (= processed total) (let ((total-count (length all-new-entries))) (when (> total-count 0) (run-with-idle-timer 0 nil (lambda () (elfeed-db-add all-new-entries)))) ;; Report results for each feed (dolist (result (nreverse results)) (message "GitHub feed %s [%s]: %d new entries" (nth 0 result) (nth 1 result) (nth 2 result))) ;; Defer UI update to avoid blocking (run-with-idle-timer 0.2 nil (lambda () (when (get-buffer "*elfeed-search*") (with-current-buffer "*elfeed-search*" (elfeed-search-update--force))))))))))))) ;; Start all async fetches (cl-loop for (repo query display-name) in elfeed-custom-github-pr-queries for i from 0 for delay = (* i elfeed-custom-github-request-delay) do (elfeed-custom-feeds-github--fetch-prs repo query display-name (lambda (r dn items) (elfeed-custom-feeds-github--prs-to-elfeed r dn items completion-callback)) delay))) nil) (provide 'elfeed-custom-feeds-github) ;;; elfeed-custom-feeds-github.el ends here