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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
|
;;; forgejo-watch.el --- Watch rules and polling for Forgejo -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Thanos Apollo
;; Author: Thanos Apollo <public@thanosapollo.org>
;; Keywords: extensions
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Per-repo watch rules with timer-based polling for Forgejo.
;;
;; Users define filter rules via `forgejo-watch-rules'. On each poll
;; interval, the system fetches matching issues/PRs from the API with
;; incremental sync (since=), saves to the local DB, and fires hooks
;; for new items.
;;
;; Enable `forgejo-watch-mode' to start polling.
;; Use `forgejo-watch-list' to browse watched items.
;;; Code:
(require 'cl-lib)
(require 'url-parse)
(require 'keymap-popup)
(require 'forgejo)
(require 'forgejo-api)
(require 'forgejo-tl)
(require 'forgejo-db)
(require 'forgejo-filter)
(require 'forgejo-utils)
(require 'forgejo-buffer)
(declare-function forgejo-issue-view "forgejo-issue.el"
(owner repo number))
(declare-function forgejo-pull-view "forgejo-pull.el"
(owner repo number))
(defvar forgejo-repo--host)
(defvar forgejo-default-sort)
(declare-function forgejo-view--list-format "forgejo-view.el" (columns))
(declare-function forgejo-api-default-limit "forgejo-api.el" ())
;;; Customization
(defcustom forgejo-watch-poll-interval 300
"Seconds between watch rule polls."
:type 'integer
:group 'forgejo)
(defcustom forgejo-watch-rules nil
"Per-repo watch rules.
Each element is a string \"owner/repo\", a cons (\"owner/repo\" . \"filter\"),
or a single-element list (\"owner/repo\"). The filter query uses the same
syntax as the interactive filter prompt. Use \"*\" to match all cached repos.
Example: ((\"guix/guix\" . \"state:open author:thanosapollo\")
(\"thanosapollo/forgejo.el\")
(\"*\" . \"author:thanosapollo\"))"
:type '(repeat (choice string
(cons string string)
(list string)))
:group 'forgejo)
(defcustom forgejo-watch-hooks nil
"Hook run when new watch items arrive.
Each function receives one argument: the list of new issue/PR alists."
:type 'hook
:group 'forgejo)
(defcustom forgejo-watch-filter-default "read:no"
"Default filter for `forgejo-watch-list'.
Uses the same syntax as the interactive filter prompt.
Supported prefixes: state:, read:, type:, author:, label:, search:."
:type 'string
:group 'forgejo)
;;; State
(defvar forgejo-watch--timer nil
"Timer for periodic watch polling.")
;;; Rule resolution
(defun forgejo-watch-resolve-rules (host)
"Expand `forgejo-watch-rules' into concrete rules for HOST.
A \"*\" repo-key expands into one rule per cached repo in the DB,
each inheriting the original filter query."
(let (result)
(dolist (rule forgejo-watch-rules)
(let* ((repo-key (if (stringp rule) rule (car rule)))
(query (if (stringp rule) nil (cdr rule))))
(if (string= repo-key "*")
(dolist (repo-path (forgejo-db-get-cached-repos host))
(push (if query (cons repo-path query) repo-path) result))
(push rule result))))
(nreverse result)))
;;; Polling
(defun forgejo-watch--poll ()
"Poll watch rules for new items on all configured hosts."
(dolist (entry forgejo-hosts)
(let* ((host-url (car entry))
(host (url-host (url-generic-parse-url host-url))))
(forgejo-watch--poll-rules host-url host))))
;;; Watch rules
(defun forgejo-watch--poll-rules (host-url host)
"Poll each watch rule in `forgejo-watch-rules' for HOST-URL/HOST."
(dolist (rule (forgejo-watch-resolve-rules host))
(forgejo-watch--poll-rule host-url host rule)))
(defun forgejo-watch--poll-rule (host-url host rule)
"Poll a single watch RULE for HOST-URL/HOST.
RULE is \"owner/repo\" or (\"owner/repo\" . \"filter-query\")."
(let* ((repo-key (if (stringp rule) rule (car rule)))
(query (if (stringp rule) nil (cdr rule)))
(parts (split-string repo-key "/"))
(owner (nth 0 parts))
(repo (nth 1 parts))
(filters (forgejo-filter-parse query))
(since (forgejo-db-get-sync-time host owner repo "watch"))
(api-filters (if since
(plist-put (copy-sequence filters) :since since)
filters))
(endpoint (format "repos/%s/%s/issues" owner repo))
(params (forgejo-filter-build-params nil api-filters
forgejo-default-sort
(forgejo-api-default-limit))))
(forgejo-api-get-paged
host-url endpoint params
(lambda (page-data _headers _page-num)
(when page-data
(forgejo-db-save-issues host owner repo page-data)))
(lambda (all-data _headers)
(when all-data
(forgejo-db-set-sync-time
host owner repo "watch"
(format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
(run-hook-with-args 'forgejo-watch-hooks all-data)
(forgejo-watch--refresh-list-buffer host))))))
(defun forgejo-watch--refresh-list-buffer (host)
"Re-render the notification list buffer for HOST if visible."
(when-let* ((buf (get-buffer "*forgejo-watch*")))
(when (get-buffer-window buf)
(with-current-buffer buf
(forgejo-watch--render host)))))
;;; Global minor mode
;;;###autoload
(define-minor-mode forgejo-watch-mode
"Toggle Forgejo notification polling.
Polls watch rules periodically for new items
and runs `forgejo-watch-hooks' when new ones arrive."
:global t
:lighter nil
:group 'forgejo
(if forgejo-watch-mode
(progn
(setq forgejo-watch--timer
(run-with-timer 0 forgejo-watch-poll-interval
'forgejo-watch--poll))
(add-hook 'kill-emacs-hook #'forgejo-watch--cleanup))
(forgejo-watch--cleanup)))
(defun forgejo-watch--cleanup ()
"Cancel the polling timer."
(when forgejo-watch--timer
(cancel-timer forgejo-watch--timer)
(setq forgejo-watch--timer nil))
(remove-hook 'kill-emacs-hook #'forgejo-watch--cleanup))
;;; Notification list buffer
(defvar-local forgejo-watch--host nil
"Hostname for the current notification list buffer.")
(defvar-local forgejo-watch--host-url nil
"Full URL for the current notification list buffer.")
(defvar-local forgejo-watch--filters nil
"Current filter plist for the notification list.")
(keymap-popup-define forgejo-watch-list-mode-map
"Forgejo watch list."
:parent tabulated-list-mode-map
:group "Actions"
"RET" ("View" forgejo-watch-view-at-point)
"r" ("Mark read" forgejo-watch-mark-read-at-point)
"R" ("Mark all read" forgejo-watch-mark-all-read)
"b" ("Open in browser" forgejo-watch-browse-at-point)
:group "Navigate"
"l" ("Filter" forgejo-watch-filter)
"g" ("Refresh" forgejo-watch-list-refresh))
(define-derived-mode forgejo-watch-list-mode tabulated-list-mode
"Forgejo Watch"
"Major mode for browsing Forgejo watch items."
:group 'forgejo
(setq tabulated-list-padding 1
tabulated-list-format (forgejo-view--list-format
forgejo-filter-notification-columns))
(tabulated-list-init-header)
(run-hooks 'forgejo-mode-hook))
(defun forgejo-watch--render (host)
"Render watch items from DB for HOST into the current buffer."
(let* ((items (forgejo-filter-query-watch
host (forgejo-watch-resolve-rules host)
forgejo-watch--filters))
(entries (forgejo-filter-notification-entries items)))
(setq tabulated-list-format (forgejo-view--list-format
forgejo-filter-notification-columns)
tabulated-list-entries entries)
(tabulated-list-init-header)
(forgejo-tl-print t)))
;;;###autoload
(defun forgejo-watch-list ()
"Browse watched items.
Shows unread items from `forgejo-watch-rules'."
(interactive)
(let* ((host-url (forgejo--resolve-host))
(host (url-host (url-generic-parse-url host-url)))
(buf (get-buffer-create "*forgejo-watch*"))
(default-filter
(forgejo-filter-parse forgejo-watch-filter-default
forgejo-filter--watch-prefix-map)))
(with-current-buffer buf
(forgejo-watch-list-mode)
(setq forgejo-watch--host host
forgejo-watch--host-url host-url
forgejo-repo--host host-url
forgejo-watch--filters default-filter)
(forgejo-watch--render host)
(switch-to-buffer buf))))
(defun forgejo-watch-list-refresh ()
"Fetch updates for watch rules, then re-render."
(interactive)
(let ((host forgejo-watch--host)
(host-url forgejo-watch--host-url))
(forgejo-watch--poll-rules host-url host)
(forgejo-watch--render host)))
;;; Filtering
(defun forgejo-watch-filter ()
"Filter the watch list."
(interactive)
(let* ((current (forgejo-filter-serialize
forgejo-watch--filters
forgejo-filter--watch-key-map))
(completions `((state . ("open" "closed"))
(read . ("yes" "no"))
(type . ("pr" "issue"))
(author . nil)
(label . nil)
(search . nil)))
(query (forgejo-utils-read-filter current completions))
(filters (forgejo-filter-parse
query forgejo-filter--watch-prefix-map)))
(setq forgejo-watch--filters filters)
(forgejo-watch--render forgejo-watch--host)))
;;; Actions
(defun forgejo-watch--parse-ref (ref)
"Parse owner/repo/number from REF.
Reads the full ref from the `forgejo-full-ref' text property."
(let ((full (or (get-text-property 0 'forgejo-full-ref ref) ref)))
(when (string-match "\\`\\([^/]+\\)/\\([^#]+\\)#\\([0-9]+\\)\\'" full)
(list (match-string 1 full)
(match-string 2 full)
(string-to-number (match-string 3 full))))))
(defun forgejo-watch-view-at-point ()
"Jump to the issue or PR for the notification at point."
(interactive)
(when-let* ((entry (tabulated-list-get-entry))
(type (aref entry 0))
(ref (aref entry 1))
(parsed (forgejo-watch--parse-ref ref)))
(let ((owner (nth 0 parsed))
(repo (nth 1 parsed))
(number (nth 2 parsed)))
(forgejo-db-mark-read forgejo-watch--host owner repo number)
(forgejo-watch--render forgejo-watch--host)
(if (string= type "PR")
(forgejo-pull-view owner repo number)
(forgejo-issue-view owner repo number)))))
(defun forgejo-watch-mark-read-at-point ()
"Mark the notification at point as read."
(interactive)
(when-let* ((entry (tabulated-list-get-entry))
(ref (aref entry 1))
(parsed (forgejo-watch--parse-ref ref)))
(forgejo-db-mark-read forgejo-watch--host
(nth 0 parsed) (nth 1 parsed) (nth 2 parsed))
(forgejo-watch--render forgejo-watch--host)))
(defun forgejo-watch-mark-all-read ()
"Mark all visible watch items as read."
(interactive)
(dolist (entry tabulated-list-entries)
(let* ((cols (cadr entry))
(ref (aref cols 1))
(parsed (forgejo-watch--parse-ref ref)))
(when parsed
(forgejo-db-mark-read forgejo-watch--host
(nth 0 parsed) (nth 1 parsed) (nth 2 parsed)))))
(forgejo-watch--render forgejo-watch--host))
(defun forgejo-watch-browse-at-point ()
"Open the issue or PR at point in the browser."
(interactive)
(when-let* ((entry (tabulated-list-get-entry))
(type (aref entry 0))
(ref (aref entry 1))
(parsed (forgejo-watch--parse-ref ref)))
(let ((browse-fn (if (string= type "PR")
#'forgejo-utils-browse-pull
#'forgejo-utils-browse-issue)))
(funcall browse-fn forgejo-watch--host-url
(nth 0 parsed) (nth 1 parsed) (nth 2 parsed)))))
(provide 'forgejo-watch)
;;; forgejo-watch.el ends here
|