blob: 49e0d6b0bceb60be808f97f16b7e6628ad9b5957 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
|
;;; 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 'elfeed-curl)
(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 ()
(elfeed-curl-enqueue url
(lambda (status)
(if (not status)
(message "Error fetching GitHub PRs %s [%s]: %s" r dn elfeed-curl-error-message)
;; Capture JSON text for async parsing
(let ((json-text (buffer-substring-no-properties (point-min) (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))))))))
:headers '(("Accept" . "application/vnd.github+json")))))))
(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 "<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>"
number title-text state user created updated
(if labels
(format "<p><strong>Labels:</strong> %s</p>"
(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 '()))
(if (zerop total)
(message "No GitHub PR queries configured")
(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))))))
(provide 'elfeed-custom-feeds-github)
;;; elfeed-custom-feeds-github.el ends here
|