├── img
└── gh-notify-screenshot.png
├── LICENSE
├── README.org
└── gh-notify.el
/img/gh-notify-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anticomputer/gh-notify/HEAD/img/gh-notify-screenshot.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 2-Clause License
2 |
3 | Copyright (c) 2021, Bas Alberts, Xristos Kalkanis
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/README.org:
--------------------------------------------------------------------------------
1 | This is gh-notify: A thin ui veneer on top of Magit/Forge porcelain for
2 | juggling large amounts of GitHub notifications at speed.
3 |
4 | #+html:
5 |
6 | It provides a more efficient interface to the Magit/Forge notification
7 | database suited for rapid searching/narrow/filter based workflows. It also
8 | improves on Magit/Forge's default notification fetching behavior by
9 | introducing support for incremental notification fetching, which is a must for
10 | interactive notification queue workflow iterations.
11 |
12 | Note: as of version 2.0.0 gh-notify relies on forge's improved and builtin
13 | incremental update fetching as well as its simplified read/unread status
14 | management.
15 |
16 | This code should be plug and play if you already have Magit/Forge set up and
17 | have fetched notifications for your GitHub account at least once. If not,
18 | please see: https://magit.vc/manual/forge.html to get started.
19 |
20 | * Getting Started
21 |
22 | 1) M-x forge-pull-notifications RET (slow)
23 | 2) M-x gh-notify RET (fast)
24 | 3) M-x describe-mode RET (docs)
25 |
26 | You only have to do bootstrap the Forge database in the slow way once, and
27 | will be able to interact with your notifications through gh-notify from that
28 | point on which, hopefully, will be a much snappier experience :)
29 |
30 | * Dependencies
31 |
32 | gh-notify requires the latest versions of Magit/Forge to be installed from
33 | melpa as per April 10, 2024. It interacts with lowlevel Forge APIs quite
34 | liberally and is sensitive to core API changes in the Forge project. If things
35 | break for you on versions installed beyond this date, please file an issue.
36 |
37 | If you are on much older versions of Magit/Forge, please use version 0.1.0 of
38 | gh-notify.
39 |
40 | * Notes
41 |
42 | ** Forge state recovery
43 |
44 | Incremental notification fetching is integrated in a Forge interoperable
45 | manner and should not conflict with your normal Forge use. It will
46 | incrementally update the Forge database and retain state across sessions.
47 |
48 | If, for whatever reason, you do want a fully clean notification slate, as
49 | opposed to the incremental/iterative experience provided by gh-notify, you can
50 | use: M-x forge-pull-notifications RET to restore a clean Forge slate.
51 |
52 | ** Read/Unread state management
53 |
54 | Forge will mark things as read with GitHub.com, but since we do not do full
55 | refreshes of all notifications, but only get new notifications since the last
56 | update, these state updates are not available in gh-notify after a forge-visit
57 | completes.
58 |
59 | As a workaround, gh-notify toggles the unread flag for the notification object
60 | in the Forge database locally to keep unread/read states synced.
61 |
62 | ** Interacting with notifications for non-local repos
63 |
64 | Contrary to popular belief, you do not need local clones of Forge repos just
65 | to interact with issues and pull requests. Most all of that data is populated
66 | via the GitHub.com rest and GraphQL API and you can edit/comment/etc. issues
67 | and even review pull requests with e.g. [[https://github.com/charignon/github-review][github-review]] just fine. In fact, that
68 | is my personal use case as well.
69 |
70 | This is my github-review config:
71 |
72 | #+BEGIN_SRC elisp
73 | ;; github-review
74 | (use-package github-review
75 | :quelpa
76 | :after forge
77 | :bind (("C-x r" . github-review-forge-pr-at-point)
78 | :map diff-mode-map ("C-c s" . my/github-review-kill-suggestion))
79 | :config
80 |
81 | (defun my/github-review-kill-suggestion ()
82 | ;; kill a region of diff+ as a review suggestion template
83 | (interactive)
84 | (setq deactivate-mark t)
85 | (let ((s-region
86 | (buffer-substring-no-properties
87 | (region-beginning)
88 | (region-end))))
89 | (kill-new
90 | (format "# ```suggestion\n%s\n# ```\n"
91 | (replace-regexp-in-string "^\\+" "# " s-region))))))
92 | #+END_SRC
93 |
94 | When opening a Forge PR from gh-notify, you can use M-x
95 | github-review-forge-pr-at-point RET and everything will work just fine, even
96 | without a local clone of the target repo.
97 |
98 | The only caveat is that magit still expects to be operating in a git
99 | repository when constructing Forge topic buffers. When a repo does not exist
100 | locally, magit errors out when the default-directory is not a git repo.
101 |
102 | As a workaround, gh-notify creates an empty git repository in
103 | =~/.gh-notify-smokescreen= and binds that path to default-directory for any
104 | forge-visit interactions to ensure we can interact with non-local topics.
105 |
106 | * Disclaimer
107 |
108 | This is highly experimental code, but I do use it in my dayjob at GitHub which
109 | involves juggling hundreds of notifications across large sets of repos on a
110 | daily basis. Everything at GitHub happens _through_ GitHub so it is imperative
111 | to me to have an effective and iterative workflow that lets me rapidly
112 | sort/narrow/filter and otherwise juggle notifications effectively. Preferably
113 | without having to step outside of Emacs.
114 |
115 | Having said that, if anything breaks in weird ways, or in corner cases that I
116 | may not be aware of, please file an issue. I'll get to it when I see the
117 | notification ;)
118 |
119 | * Acknowledgements
120 |
121 | All of the awesome high-speed filtering is based on code written by Xristos
122 |
123 |
124 | He is an absolute monster when it comes to anything involving parentheses and
125 | remains an inspiration in the software engineering field.
126 |
127 | I would also like to acknowledge Jonas Bernoulli for his amazing work on the
128 | Magit/Forge project.
129 |
130 | * Licensing
131 |
132 | #+BEGIN_EXAMPLE
133 | Copyright (C) 2021 bas@anti.computer
134 | 2020 xristos@sdf.org
135 |
136 | All rights reserved
137 |
138 | Redistribution and use in source and binary forms, with or without
139 | modification, are permitted provided that the following conditions
140 | are met:
141 |
142 | * Redistributions of source code must retain the above copyright
143 | notice, this list of conditions and the following disclaimer.
144 |
145 | * Redistributions in binary form must reproduce the above
146 | copyright notice, this list of conditions and the following
147 | disclaimer in the documentation and/or other materials
148 | provided with the distribution.
149 |
150 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
151 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
152 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
153 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
154 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
155 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
156 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
157 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
158 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
159 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
160 | POSSIBILITY OF SUCH DAMAGE.
161 |
162 | This project includes code modified from:
163 |
164 | Magit/Forge (https://github.com/magit/forge)
165 | Copyright (C) 2018-2021 Jonas Bernoulli
166 |
167 | Magit/Forge modifications are subject to the following license terms:
168 |
169 | Forge is free software; you can redistribute it and/or modify it
170 | under the terms of the GNU General Public License as published by
171 | the Free Software Foundation; either version 3, or (at your option)
172 | any later version.
173 |
174 | Forge is distributed in the hope that it will be useful, but WITHOUT
175 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
176 | or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
177 | License for more details.
178 |
179 | You should have received a copy of the GNU General Public License
180 | along with Forge. If not, see http://www.gnu.org/licenses.
181 |
182 | This project includes code modified from:
183 |
184 | chrome.el (https://github.com/anticomputer/chrome.el)
185 | Copyright (C) 2020 xristos@sdf.org
186 | 2020 bas@anti.computer
187 |
188 | More specifically it repurposes the text filtering and rendering engine
189 | developed by Xristos for chrome.el.
190 |
191 | All his original author credits and licensing terms apply.
192 | #+END_EXAMPLE
193 |
--------------------------------------------------------------------------------
/gh-notify.el:
--------------------------------------------------------------------------------
1 | ;; -*- lexical-binding: t; -*-
2 |
3 | ;;; gh-notify.el --- A veneer for Magit/Forge GitHub notifications
4 |
5 | ;; Copyright (C) 2021 bas@anti.computer
6 | ;; 2020 xristos@sdf.org
7 | ;;
8 | ;; All rights reserved
9 |
10 | ;; Modified: 2024-04-10
11 | ;; Version: 2.1.0
12 | ;; Author: Bas Alberts
13 | ;; xristos
14 | ;;
15 | ;; Maintainer: Bas Alberts
16 | ;; URL: https://github.com/anticomputer/gh-notify
17 | ;; Package-Requires: ((emacs "29.1") (magit "3.3.0") (forge "0.4.0"))
18 | ;; Keywords: comm
19 |
20 | ;; Redistribution and use in source and binary forms, with or without
21 | ;; modification, are permitted provided that the following conditions
22 | ;; are met:
23 | ;;
24 | ;; * Redistributions of source code must retain the above copyright
25 | ;; notice, this list of conditions and the following disclaimer.
26 | ;;
27 | ;; * Redistributions in binary form must reproduce the above
28 | ;; copyright notice, this list of conditions and the following
29 | ;; disclaimer in the documentation and/or other materials
30 | ;; provided with the distribution.
31 | ;;
32 | ;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
33 | ;; AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
34 | ;; IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
35 | ;; ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
36 | ;; LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
37 | ;; CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
38 | ;; SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
39 | ;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
40 | ;; CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
41 | ;; ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
42 | ;; POSSIBILITY OF SUCH DAMAGE.
43 |
44 | ;; This project includes code modified from:
45 | ;;
46 | ;; Magit/Forge (https://github.com/magit/forge)
47 | ;; Copyright (C) 2018-2021 Jonas Bernoulli
48 | ;;
49 | ;; Magit/Forge modifications are subject to the following license terms:
50 | ;;
51 | ;; Forge is free software; you can redistribute it and/or modify it
52 | ;; under the terms of the GNU General Public License as published by
53 | ;; the Free Software Foundation; either version 3, or (at your option)
54 | ;; any later version.
55 | ;;
56 | ;; Forge is distributed in the hope that it will be useful, but WITHOUT
57 | ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
58 | ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
59 | ;; License for more details.
60 | ;;
61 | ;; You should have received a copy of the GNU General Public License
62 | ;; along with Forge. If not, see http://www.gnu.org/licenses.
63 |
64 | ;; This project includes code modified from:
65 | ;;
66 | ;; chrome.el (https://github.com/anticomputer/chrome.el)
67 | ;; Copyright (C) 2020 xristos@sdf.org
68 | ;; 2020 bas@anti.computer
69 | ;;
70 | ;; More specifically it repurposes the text filtering and rendering engine
71 | ;; developed by Xristos for chrome.el.
72 | ;;
73 | ;; All his original author credits and licensing terms apply.
74 |
75 | ;;; Commentary:
76 |
77 | ;; This is gh-notify: A thin ui veneer on top of Magit/Forge porcelain for juggling
78 | ;; large amounts of GitHub notifications.
79 | ;;
80 | ;; It provides a more efficient interface to the Magit/Forge notification database
81 | ;; suited for rapid searching/narrow/filter based workflows. It also improves on
82 | ;; Magit/Forge's default notification fetching behavior by introducing support for
83 | ;; incremental notification fetching, which is a must for interactive notification
84 | ;; queue workflow iterations.
85 | ;;
86 | ;; This code should be plug and play if you already have Magit/Forge set up and have
87 | ;; fetched notifications for your GitHub account at least once. If not, please see:
88 | ;; https://magit.vc/manual/forge.html to get started.
89 | ;;
90 | ;; Note: this requires the latest versions of magit/forge to be installed from melpa
91 | ;; as per Feb 18, 2021.
92 | ;;
93 | ;;; Usage:
94 | ;;
95 | ;; M-x gh-notify
96 | ;;
97 | ;; Please see README.org for documentation.
98 |
99 | ;;; Code:
100 |
101 | (require 'magit)
102 | (require 'forge)
103 | (require 'iso8601)
104 |
105 | (require 'url)
106 | (require 'json)
107 | (require 'subr-x)
108 | (require 'cl-lib)
109 | (require 'auth-source)
110 | (require 'url-util)
111 |
112 | (defgroup gh-notify nil
113 | "GitHub magit/forge notifications control."
114 | :group 'comm)
115 |
116 | (defface gh-notify-notification-filter-face
117 | '((((class color) (background dark)) (:foreground "#aaffaa"))
118 | (((class color) (background light)) (:foreground "#5faf00")))
119 | "Face used to display current filter."
120 | :group 'gh-notify)
121 |
122 | (defface gh-notify-notification-marked-face
123 | '((((class color) (background dark)) (:foreground "#ffaaff"))
124 | (((class color) (background light)) (:foreground "#d70008")))
125 | "Marked face."
126 | :group 'gh-notify)
127 |
128 | (defface gh-notify-notification-unread-face
129 | '((((class color) (background dark)) (:weight ultra-bold))
130 | (((class color) (background light)) (:weight ultra-bold)))
131 | "Unread face."
132 | :group 'gh-notify)
133 |
134 | (defface gh-notify-notification-repo-face
135 | '((((class color) (background dark)) (:weight ultra-light))
136 | (((class color) (background light)) (:weight ultra-light)))
137 | "Repo face."
138 | :group 'gh-notify)
139 |
140 | (defface gh-notify-notification-reason-face
141 | '((((class color) (background dark)) (:foreground "#aaffaa"))
142 | (((class color) (background light)) (:background "#5faf00")))
143 | "Reason face."
144 | :group 'gh-notify)
145 |
146 | (defface gh-notify-notification-issue-face
147 | '((((class color) (background dark)) (:foreground "#ff9999"))
148 | (((class color) (background light)) (:background "#ff9999")))
149 | "Issue face."
150 | :group 'gh-notify)
151 |
152 | (defface gh-notify-notification-pr-face
153 | '((((class color) (background dark)) (:foreground "#ffff99"))
154 | (((class color) (background light)) (:background "#ffff99")))
155 | "PR face."
156 | :group 'gh-notify)
157 |
158 | (defface gh-notify-notification-discussion-face
159 | '((((class color) (background dark)) (:foreground "#7cb9e8"))
160 | (((class color) (background light)) (:background "#7cb9e8")))
161 | "Discussion face."
162 | :group 'gh-notify)
163 |
164 | (defvar gh-notify-render-function #'gh-notify-render-notification
165 | "Function that renders a notification into a string for display.
166 |
167 | The function must accept one argument, an gh-notify-notification instance,
168 | and return a string that must not span more than one line.")
169 |
170 | (defvar gh-notify-limit-function #'gh-notify-limit-notification
171 | "Function that limits visible notifications based on certain criteria.
172 |
173 | Function must accept one argument, an gh-notify-notification instance, and
174 | return t if the notification is included in the limit, nil otherwise.")
175 |
176 | (defvar gh-notify-filter-function #'gh-notify-filter-notification
177 | "Function that filters visible notifications based on a user-typed regexp.
178 |
179 | Function must accept one argument, gh-notify-notification instance, and
180 | return t if the notification passes the filter, nil otherwise. The current
181 | filter can be retrieved by calling `gh-notify-active-filter'.")
182 |
183 | (defvar gh-notify-show-timing t
184 | "Measure and display elapsed time after every operation.
185 |
186 | This can be toggled by `gh-notify-toggle-timing'.")
187 |
188 | (defvar gh-notify-show-state nil
189 | "Show open/closed/merged state by default.
190 |
191 | This requires an additional forge db query for every notification and makes
192 | inits/refreshes SIGNIFICANTLY less snappy. Disabled by default and recommended
193 | to use `gh-notify-display-state' via a keybinding instead.")
194 |
195 | (defvar gh-notify-default-view :title
196 | "Show notification titles when equal to :title, URLs otherwise.
197 | This can be toggled by `gh-notify-toggle-url-view'.")
198 |
199 | (defvar gh-notify-reason-limit '(:all)
200 | "Default display limit.
201 | Can be a list of reason keywords that will be combined with OR logic.
202 | Supported reasons: :assign, :mention, :team_mention, :subscribed,
203 | :author, :comment, :review-requested, :mark, :unread, :all.")
204 |
205 | (defvar gh-notify-default-repo-limit '()
206 | "List of default repo limits.
207 |
208 | Limits are in the form \"owner/repo\".
209 |
210 | This will be your repo limit reset state to the exclusion of anything else.
211 | and should only be used if you have a lot of permanent-noise from repositories
212 | you do not care about for the long term.
213 |
214 | Most people will want to keep this list empty and use
215 | `gh-notify-exclude-repo-limit' instead.")
216 |
217 | (defvar gh-notify-exclude-repo-limit '()
218 | "List of repos to exclude from notifications display.
219 |
220 | Repos are in the form \"owner/repo\".
221 |
222 | Use this to muzzle specific repos that you want to silence across sessions.")
223 |
224 | (defvar gh-notify-redraw-on-visit t
225 | "Automatically redraw notifications on `forge-visit'.
226 |
227 | If you prefer to manually refresh notification display state after visits, set
228 | this to nil.")
229 |
230 | (cl-defstruct (gh-notify-notification
231 | (:constructor gh-notify-notification-create)
232 | (:copier nil))
233 | (forge-obj nil :read-only nil)
234 | (topic-obj nil :read-only nil)
235 | (id nil :read-only t)
236 | (type nil :read-only t)
237 | (topic nil :read-only t)
238 | (number nil :read-only t)
239 | (repo-id nil :read-only t)
240 | (repo nil :read-only t)
241 | (unread nil :read-only nil)
242 | (status nil :read-only nil)
243 | (updated nil :read-only t)
244 | (ts nil :read-only t)
245 | (date nil :read-only t)
246 | (reason nil :read-only t)
247 | (url nil :read-only t)
248 | (title nil :read-only t)
249 | (state nil :read-only t)
250 | is-marked
251 | line)
252 |
253 | ;;;
254 | ;;; Internal API
255 | ;;;
256 |
257 | (defvar gh-notify--current-buffer nil
258 | "We have to play weird callback magic with Forge with buffer-local capabilities.")
259 |
260 | (defvar-local gh-notify--repo-limit '()
261 | "Repo filter list.")
262 |
263 | (defvar-local gh-notify--unread-limit nil
264 | "State limit.")
265 |
266 | (defvar-local gh-notify--type-limit nil
267 | "Type limit.")
268 |
269 | (defvar-local gh-notify--repo-index nil
270 | "Hash table that contains indexed `gh-notify' notifications.
271 |
272 | Keys are repos, strings of form \"owner/repo\". Values are conses of form:
273 |
274 | (notification-count . notification-list)")
275 |
276 | (defun gh-notify--reindex-notifications (notifications)
277 | "Index NOTIFICATIONS into `gh-notify--repo-index'.
278 | NOTIFICATIONS must be an alist as returned from `gh-notify-get-notifications'."
279 | (clrhash gh-notify--repo-index)
280 | (cl-loop
281 | for (repo . notification-data) in notifications
282 | for repo-id = (format "%s/%s" (oref repo owner) (oref repo name))
283 | for notification-count = 0
284 | for process-notifications = nil
285 | do
286 | (cl-loop
287 | for index from 0
288 | for forge-notification in notification-data
289 | ;; get timestamp as an emacs time value to juggle
290 | for ts = (encode-time (iso8601-parse (oref forge-notification updated)))
291 | for date = (format-time-string "%F" ts) ; use local time on our end for display
292 | for type = (oref forge-notification type)
293 | for topic = (oref forge-notification topic)
294 | for topic-obj = (and topic
295 | (pcase type
296 | ('discussion
297 | (forge-get-discussion topic))
298 | ('issue
299 | (forge-get-issue topic))
300 | ('pullreq
301 | (forge-get-pullreq topic))))
302 | for status = (when topic-obj (oref topic-obj status))
303 | for number = (when topic-obj (oref topic-obj number))
304 | for id = (oref forge-notification id)
305 | for reason = (oref forge-notification reason)
306 | for updated = (oref forge-notification updated)
307 | for url = (oref forge-notification url)
308 | for title = (oref forge-notification title)
309 | for state = (when gh-notify-show-state
310 | (gh-notify--get-topic-state
311 | (oref forge-notification type) repo
312 | (oref forge-notification topic)))
313 | do
314 | (let* ((notification
315 | (gh-notify-notification-create
316 | ;; retain the obj ref for db interactions
317 | :forge-obj forge-notification
318 | :topic-obj topic-obj
319 | ;; yank all the forge crud for convenience
320 | :id id
321 | :reason reason
322 | :updated updated
323 | :topic topic
324 | :number number
325 | :type type
326 | :repo-id repo-id
327 | :repo repo
328 | ;; Modern Forge uses topic status scheme (unread/pending/done)
329 | :unread (eq status 'unread)
330 | :status status
331 | :url url
332 | :title title
333 | ;; we use this for an accurate sort
334 | :ts ts
335 | :date date
336 | :state state)))
337 | (push notification process-notifications))
338 | finally (cl-incf notification-count index))
339 | ;; A hash table indexed by repo-id containing all notifications
340 | (setf (gethash repo-id gh-notify--repo-index)
341 | ;; sort notifications by timestamp and then reverse for display
342 | (cons notification-count
343 | (cl-sort process-notifications
344 | (lambda (a b) (not (time-less-p a b)))
345 | :key #'gh-notify-notification-ts)))))
346 |
347 | (defvar-local gh-notify--visible-notifications nil)
348 | (defvar-local gh-notify--marked-notifications '())
349 | (defvar-local gh-notify--total-notification-count 0)
350 |
351 | (defun gh-notify--init-caches ()
352 | "Init caches."
353 | (setq gh-notify--repo-index (make-hash-table :test 'equal)
354 | gh-notify--visible-notifications (make-hash-table)))
355 |
356 | (defvar-local gh-notify--start-time nil)
357 | (defvar-local gh-notify--elapsed-time nil)
358 |
359 | (defun gh-notify--start-timer ()
360 | "Start timer."
361 | (unless gh-notify--start-time
362 | (setq gh-notify--start-time (current-time))))
363 |
364 | (defun gh-notify--stop-timer ()
365 | "Stop timer."
366 | (when gh-notify--start-time
367 | (setq gh-notify--elapsed-time
368 | (float-time (time-subtract
369 | (current-time)
370 | gh-notify--start-time))
371 | gh-notify--start-time nil)))
372 |
373 | (defvar-local gh-notify--header-update nil)
374 |
375 | (cl-defmacro gh-notify--with-timing (&body body)
376 | "Time BODY."
377 | (declare (indent defun))
378 | `(unwind-protect
379 | (progn
380 | (gh-notify--start-timer)
381 | ,@body)
382 | (gh-notify--stop-timer)
383 | (setq gh-notify--header-update t)))
384 |
385 | (defun gh-notify--message (format-string &rest args)
386 | "Message ARGS as FORMAT-STRING."
387 | (let ((message-truncate-lines t))
388 | (message "gh-notify: %s" (apply #'format format-string args))))
389 |
390 | ;;;
391 | ;;; Filtering
392 | ;;;
393 |
394 | (defvar-local gh-notify--active-filter nil)
395 | (defvar-local gh-notify--last-notification nil)
396 | (defvar-local gh-notify--global-ts-sort t)
397 |
398 | (defsubst gh-notify--goto-line (line)
399 | "Goto LINE."
400 | (goto-char (point-min))
401 | (forward-line (1- line)))
402 |
403 | (defsubst gh-notify--render-notification (notification &optional skip-goto)
404 | "Render NOTIFICATION optionally SKIP-GOTO."
405 | (unless skip-goto (gh-notify-goto-notification notification))
406 | (delete-region (line-beginning-position) (line-end-position))
407 | (insert (funcall gh-notify-render-function notification)))
408 |
409 | (defsubst gh-notify--limit-notification (notification)
410 | "Limit NOTIFICATION."
411 | (funcall gh-notify-limit-function notification))
412 |
413 | (defsubst gh-notify--filter-notification (notification)
414 | "Filter NOTIFICATION."
415 | (funcall gh-notify-filter-function notification))
416 |
417 | (defun gh-notify--filter-notifications ()
418 | "Filter notifications."
419 | (when-let ((current-notification (gh-notify-current-notification)))
420 | (setq gh-notify--last-notification current-notification))
421 | (when (> (buffer-size) 0)
422 | (let ((inhibit-read-only t))
423 | (erase-buffer))
424 | (clrhash gh-notify--visible-notifications))
425 |
426 | ;; resorting * every time we re-filter is not the most optimal of things :P
427 | (gh-notify--with-timing
428 | (cl-loop
429 | for repo-id being the hash-keys of gh-notify--repo-index
430 | for repo-notifications = (cdr (gethash repo-id gh-notify--repo-index))
431 | with line = 1
432 | with ts-sorted-notifications = '()
433 | do
434 | ;; collect all notifications into a single list ... concat is faster here
435 | (setq ts-sorted-notifications
436 | (cl-concatenate 'list ts-sorted-notifications repo-notifications))
437 | finally do
438 | (progn
439 | ;; by default notifications are grouped and sorted by their repo blocks
440 | ;; but this overrides that behavior and lets you re-sort * by timestamp
441 | (when gh-notify--global-ts-sort
442 | (setq ts-sorted-notifications
443 | (cl-sort ts-sorted-notifications
444 | (lambda (a b) (not (time-less-p a b)))
445 | :key #'gh-notify-notification-ts)))
446 | ;; filter as a sorted whole list instead of per-repo chunks
447 | (cl-loop
448 | for notification in ts-sorted-notifications do
449 | (progn
450 | ;; Matching
451 | (if (and (gh-notify--limit-notification notification)
452 | (gh-notify--filter-notification notification))
453 | ;; Matches filter+limit
454 | (let ((inhibit-read-only t))
455 | (setf (gh-notify-notification-line notification) line
456 | (gethash line gh-notify--visible-notifications) notification
457 | line (1+ line))
458 | (gh-notify--render-notification notification t)
459 | (insert "\n"))
460 | ;; Doesn't match filter/limit
461 | (setf (gh-notify-notification-line notification) nil))))
462 |
463 | ;; After all notifications have been filtered, determine where to set point
464 | (when (> line 1)
465 | ;; Previously selected notification if it's still visible
466 | (if-let ((last-notification gh-notify--last-notification)
467 | (last-line (gh-notify-notification-line last-notification)))
468 | (gh-notify-goto-notification last-notification)
469 | (goto-char (point-min)))))))
470 |
471 | (force-mode-line-update))
472 |
473 |
474 | ;;;
475 | ;;; Header
476 | ;;;
477 |
478 | (defvar-local gh-notify--header-function #'gh-notify--header
479 | "Function that returns a string for notification view header line.")
480 |
481 | (defun gh-notify--header-1 ()
482 | "Generate string for notification view header line."
483 | (let* ((visible-notifications (hash-table-count gh-notify--visible-notifications))
484 | (total-repos (hash-table-count gh-notify--repo-index)))
485 | (cl-flet ((align (width str)
486 | (let ((spec (format "%%%ds" width)))
487 | (format spec str)))
488 | (size10 (x) (if (= x 0) 1 (1+ (floor (log x 10))))))
489 | (concat
490 | (align (+ 1 (* 2 (size10 gh-notify--total-notification-count)))
491 | (propertize (format "%s/%s" visible-notifications gh-notify--total-notification-count)
492 | 'help-echo "Visible / total notifications"))
493 | " "
494 | (align (size10 gh-notify--total-notification-count)
495 | (propertize (int-to-string (length gh-notify--marked-notifications))
496 | 'help-echo "Marked notifications"
497 | 'face 'gh-notify-notification-marked-face))
498 | " "
499 | (align (1+ (* 2 (size10 total-repos)))
500 | (propertize (format "(%s)" total-repos)
501 | 'help-echo "Total repos"))
502 | " "
503 | (format "by: %s " (if gh-notify--global-ts-sort "date" "repo"))
504 | (when gh-notify--unread-limit
505 | (format "%s " gh-notify--unread-limit))
506 | (when gh-notify--type-limit (format "type: %s " gh-notify--type-limit))
507 | (format "reason: %s "
508 | (mapconcat (lambda (r) (substring (symbol-name r) 1))
509 | gh-notify-reason-limit
510 | ","))
511 | ;; if it's active, you already know which repos are in the filter, if not you don't care
512 | (when gh-notify--repo-limit ":repo ")
513 | (when gh-notify-show-timing
514 | (propertize (format " %.4fs " gh-notify--elapsed-time)
515 | 'help-echo "Elapsed time for last operation"))
516 | (when-let ((filter (gh-notify-active-filter)))
517 | (format "Search: %s"
518 | (propertize filter
519 | 'help-echo "Search filter"
520 | 'face 'gh-notify-notification-filter-face)))))))
521 |
522 | (defvar-local gh-notify--header-cache nil)
523 |
524 | (defun gh-notify--header ()
525 | "Return string for notification view header line.
526 | If a previously cached string is still valid, it is returned.
527 | Otherwise, a new string is generated and returned by calling
528 | `gh-notify--header-1'."
529 | (if (and (null gh-notify--header-update)
530 | (eql (car gh-notify--header-cache) (buffer-modified-tick)))
531 | (cdr gh-notify--header-cache)
532 | (let ((header (gh-notify--header-1)))
533 | (prog1 header
534 | (setq gh-notify--header-cache (cons (buffer-modified-tick) header)
535 | gh-notify--header-update nil)))))
536 |
537 |
538 | ;;;
539 | ;;; Major mode
540 | ;;;
541 |
542 |
543 | (defvar gh-notify-mode-map
544 | ;; Override self-insert-command with fallback to global-map
545 | (let* ((map (make-keymap))
546 | (prefix-map (make-sparse-keymap))
547 | (char-table (cl-second map)))
548 | ;; Rebind keys that were bound to self-insert-command
549 | (map-keymap
550 | (lambda (event def)
551 | (when (eq def 'self-insert-command)
552 | (set-char-table-range
553 | char-table event 'gh-notify--self-insert-command)))
554 | global-map)
555 | ;; Standard bindings
556 | (define-key map (kbd "DEL") 'gh-notify--self-insert-command)
557 | (define-key map (kbd "C-c C-l") 'gh-notify-retrieve-notifications)
558 | (define-key map (kbd "C-c C-k") 'gh-notify-reset-filter)
559 | (define-key map (kbd "C-c C-t") 'gh-notify-toggle-timing)
560 | (define-key map (kbd "C-c C-w") 'gh-notify-copy-url)
561 | (define-key map (kbd "C-c C-s") 'gh-notify-display-state)
562 | (define-key map (kbd "C-c C-i") 'gh-notify-ls-issues-at-point) ; all on prefix, open by default
563 | (define-key map (kbd "C-c C-p") 'gh-notify-ls-pullreqs-at-point) ; all on prefix, open by default
564 | (define-key map (kbd "G") 'gh-notify-forge-refresh)
565 | (define-key map (kbd "RET") 'gh-notify-visit-notification) ; browse-url on prefix
566 | (define-key map (kbd "C-c C-v") 'gh-notify-forge-visit-repo-at-point)
567 | (define-key map (kbd "M-m") 'gh-notify-mark-notification)
568 | (define-key map (kbd "M-M") 'gh-notify-mark-all-notifications)
569 | (define-key map (kbd "M-u") 'gh-notify-unmark-notification)
570 | (define-key map (kbd "M-U") 'gh-notify-unmark-all-notifications)
571 | (define-key map (kbd "C-") 'previous-line)
572 | (define-key map (kbd "C-") 'next-line)
573 | (define-key map (kbd "\\") 'gh-notify-toggle-url-view)
574 | ;; Prefix bindings
575 | (define-key map (kbd "/") prefix-map)
576 | ;; toggle date/repo sort under this prefix-map for better flow
577 | (define-key prefix-map (kbd "d") 'gh-notify-toggle-global-ts-sort) ; date/repo sort toggle
578 | ;; state (read/unread) limit control
579 | (define-key prefix-map (kbd "u") 'gh-notify-limit-unread) ; resets unread limit on prefix
580 | ;; repo limit control
581 | (define-key prefix-map (kbd "'") 'gh-notify-limit-repo) ; pushes by default, pops on prefix
582 | (define-key prefix-map (kbd "\"") 'gh-notify-limit-repo-none) ; resets to default repo limit
583 | ;; type limit control
584 | (define-key prefix-map (kbd "p") 'gh-notify-limit-pr) ; resets type limit on prefix
585 | (define-key prefix-map (kbd "i") 'gh-notify-limit-issue) ; resets type limit on prefix
586 | ;; reason limit control
587 | (define-key prefix-map (kbd "*") 'gh-notify-limit-marked)
588 | (define-key prefix-map (kbd "a") 'gh-notify-limit-assign)
589 | (define-key prefix-map (kbd "y") 'gh-notify-limit-author)
590 | (define-key prefix-map (kbd "m") 'gh-notify-limit-mention)
591 | (define-key prefix-map (kbd "t") 'gh-notify-limit-team-mention)
592 | (define-key prefix-map (kbd "s") 'gh-notify-limit-subscribed)
593 | (define-key prefix-map (kbd "c") 'gh-notify-limit-comment)
594 | (define-key prefix-map (kbd "r") 'gh-notify-limit-review-requested)
595 | (define-key prefix-map (kbd "/") 'gh-notify-limit-none) ; resets reason limit
596 | map)
597 | "Keymap for `gh-notify-mode'.")
598 |
599 | (defun gh-notify--self-insert-command ()
600 | "Insert command."
601 | (interactive)
602 | (let ((event last-input-event)
603 | updated)
604 | (cond ((characterp event)
605 | (if (and (= 127 event)
606 | (not (display-graphic-p)))
607 | (pop gh-notify--active-filter)
608 | (push event gh-notify--active-filter))
609 | (setq updated t))
610 | ((eql event 'backspace)
611 | (pop gh-notify--active-filter)
612 | (setq updated t))
613 | (t (gh-notify--message "Unknown event %s" event)))
614 | (when updated (gh-notify--filter-notifications))))
615 |
616 | (defun gh-notify-mode ()
617 | "Major mode for manipulating GitHub notifications through Magit/Forge.
618 |
619 | \\
620 |
621 | Notifications are retrieved from Magit/Forge and displayed in an Emacs buffer,
622 | one notification per line. Display takes place in date/repo or repo/date
623 | ordered fashion.
624 |
625 | Notifications can be further filtered in realtime by a user-specified regular
626 | expression and limited by certain criteria described below. This mode tries to
627 | remember point so that it keeps its associated notification selected across
628 | filtering/limiting operations, assuming the notification is visible.
629 |
630 | To minimize the feedback loop, this mode does not use the minibuffer for input
631 | \(e.g. when typing a filter regular expression). You can start typing
632 | immediately and the filter updates, visible on the header line.
633 |
634 | Other than regular keys being bound to `gh-notify--self-insert-command', the
635 | following commands are available:
636 |
637 | Type \\[gh-notify-visit-notification] to switch to notification at point in
638 | magit/forge. With a prefix argument, switch to the topic associated to
639 | notification through `browse-url'.
640 |
641 | Type \\[gh-notify-retrieve-notifications] to retrieve local notifications from magit/forge.
642 |
643 | Type \\[gh-notify-forge-refresh] to retrieve new remote notifications from GitHub.
644 |
645 | Type \\[gh-notify-reset-filter] to kill the current Title/URL filter.
646 |
647 | Type \\[gh-notify-toggle-url-view] to toggle notifications being shown as titles or API URLs.
648 |
649 | Type \\[gh-notify-toggle-timing] to toggle timing information on the header line.
650 |
651 | Type \\[gh-notify-copy-url] to copy API URL belonging to notification at point.
652 |
653 | Type \\[gh-notify-ls-issues-at-point] to visit any other open issue associated
654 | to the repo of the notification at point. With a prefix any of all issues
655 | associated to the repo of the notification at point.
656 |
657 | Type \\[gh-notify-ls-pullreqs-at-point] to visit any other open pull request
658 | associated to the repo of the notification at point. With a prefix any of all
659 | issues associated to the repo of the notification at point.
660 |
661 | Limiting notifications:
662 |
663 | Gh-notify operates on four layers of result limiting, a read-state limit, a
664 | type limit, repo limit and a reason limit.
665 |
666 | These are applied in repo -> state -> type -> reason order, which is generally
667 | what you want. This allows you to intuitively add and remove limits. Repo
668 | narrows to repo scope, state toggles for unread/read, type narrows for
669 | notification type (issue, pullreq) and finally reason narrows on the reason
670 | for the notification.
671 |
672 | Repo limits:
673 |
674 | Type \\[gh-notify-limit-repo] to add a repo to the repo limit. With a prefix argument, remove
675 | a repo from the repo limit.
676 |
677 | Type \\[gh-notify-limit-repo-none] to reset the repo limit to the default limit.
678 |
679 | State limits:
680 |
681 | Type \\[gh-notify-limit-unread] to limit to unread notifications. With a prefix argument,
682 | remove unread limit.
683 |
684 | Reason limits:
685 |
686 | Independently from the repo limit are the various reason limits. These
687 | correlate to the various notification reason states that may be associated
688 | with a GitHub notification.
689 |
690 | Type \\[gh-notify-limit-issue] to limit to issue notifications.
691 |
692 | Type \\[gh-notify-limit-pr] to limit to pull request notifications.
693 |
694 | Type \\[gh-notify-limit-assign] to toggle assign notifications.
695 |
696 | Type \\[gh-notify-limit-author] to toggle author notifications.
697 |
698 | Type \\[gh-notify-limit-mention] to toggle mention notifications.
699 |
700 | Type \\[gh-notify-limit-team-mention] to toggle team-mention notifications.
701 |
702 | Type \\[gh-notify-limit-subscribed] to toggle subscribed notifications.
703 |
704 | Type \\[gh-notify-limit-comment] to toggle comment notifications.
705 |
706 | Type \\[gh-notify-limit-review-requested] to toggle review-requested notifications.
707 |
708 | Type \\[gh-notify-limit-marked] to toggle marked notifications.
709 |
710 | All reason filters can be stacked - multiple reasons can be active at once.
711 |
712 | Type \\[gh-notify-limit-none] to remove all reason filters and show all notifications.
713 |
714 | Marking:
715 |
716 | Type \\[gh-notify-mark-notification] to mark notification at point.
717 |
718 | Type \\[gh-notify-unmark-notification] to unmark notification at point.
719 |
720 | Type \\[gh-notify-mark-all-notifications] to mark all notifications currently visible in Emacs. If
721 | there is a region, only mark notifications in region.
722 |
723 | Type \\[gh-notify-unmark-all-notifications] to unmark all notifications currently visible in Emacs.
724 | If there is a region, only unmark notifications in region.
725 |
726 | Note: marking support is currently moot, but will be used to support bulk
727 | actions.
728 |
729 | \\{gh-notify-mode-map}"
730 | (interactive)
731 | (kill-all-local-variables)
732 | (use-local-map gh-notify-mode-map)
733 | (font-lock-mode -1)
734 | (make-local-variable 'font-lock-function)
735 | (buffer-disable-undo)
736 | (setq major-mode 'gh-notify-mode
737 | mode-name "Gh-Notify"
738 | truncate-lines t
739 | buffer-read-only t
740 | header-line-format '(:eval (funcall gh-notify--header-function))
741 | font-lock-function (lambda (_) nil)
742 | gh-notify--repo-limit gh-notify-default-repo-limit
743 | gh-notify--current-buffer (current-buffer))
744 | (gh-notify--init-caches)
745 | (gh-notify--with-timing
746 | (gh-notify--reindex-notifications (gh-notify-get-notifications))
747 | (gh-notify--filter-notifications))
748 | (hl-line-mode)
749 | (run-mode-hooks 'gh-notify-mode-hook))
750 |
751 | ;;;
752 | ;;; API
753 | ;;;
754 |
755 | (defun gh-notify-active-filter ()
756 | "Return currently active filter string or nil."
757 | (when gh-notify--active-filter
758 | (apply #'string (reverse gh-notify--active-filter))))
759 |
760 | (defun gh-notify-render-notification (notification)
761 | "Return string representation of NOTIFICATION.
762 | String is used as is to display NOTIFICATION in *github-notifications* buffer.
763 | It must not span more than one line but it may contain text properties."
764 | (let ((repo-id (gh-notify-notification-repo-id notification))
765 | (type (gh-notify-notification-type notification))
766 | (url (gh-notify-notification-url notification))
767 | (title (gh-notify-notification-title notification))
768 | (is-marked (gh-notify-notification-is-marked notification))
769 | (unread (gh-notify-notification-unread notification))
770 | (reason (gh-notify-notification-reason notification))
771 | (date (gh-notify-notification-date notification))
772 | (number (gh-notify-notification-number notification))
773 | (state (gh-notify-notification-state notification)))
774 | (let* ((unread-str
775 | (cond (is-marked
776 | "*")
777 | (unread
778 | "U")
779 | ((not unread)
780 | "R")))
781 | (date-str (format "%s" date))
782 | (type-str
783 | (cond
784 | ((eq type 'pullreq)
785 | "P")
786 | ((eq type 'issue)
787 | "I")
788 | ((eq type 'discussion)
789 | "D")
790 | (t
791 | "?")))
792 | (state-str
793 | (if gh-notify-show-state
794 | (cond
795 | ((eq state 'open)
796 | "O")
797 | ((eq state 'closed)
798 | "C")
799 | ((eq state 'merged)
800 | "M")
801 | (t
802 | "."))
803 | ""))
804 | (repo-str (format "%s #%s" repo-id number))
805 | (reason-str (format "[%s]" reason))
806 | (desc-str
807 | (if (eq gh-notify-default-view :title)
808 | (if (string-equal "" title) url title)
809 | url)))
810 |
811 | ;; use type as a visual marker for issue|pullreq
812 | (pcase type
813 | ('pullreq
814 | (setq type-str (propertize type-str 'face 'gh-notify-notification-pr-face)))
815 | ('issue
816 | (setq type-str (propertize type-str 'face 'gh-notify-notification-issue-face)))
817 | ('discussion
818 | (setq type-str (propertize type-str 'face 'gh-notify-notification-discussion-face))))
819 |
820 | ;; repo face is our default face for most components
821 | (setq date-str (propertize date-str 'face 'gh-notify-notification-repo-face))
822 | (setq state-str (propertize state-str 'face 'gh-notify-notification-repo-face))
823 | (setq unread-str (propertize unread-str 'face 'gh-notify-notification-repo-face))
824 | (setq repo-str (propertize repo-str 'face 'gh-notify-notification-repo-face))
825 | (setq reason-str (propertize reason-str 'face 'gh-notify-notification-reason-face))
826 |
827 | ;; use the tail of the line for any mutex global state marker like marked/unread
828 | (cond
829 | (is-marked
830 | (setq date-str (propertize date-str 'face 'gh-notify-notification-marked-face))
831 | (setq unread-str (propertize unread-str 'face 'gh-notify-notification-marked-face))
832 | (setq repo-str (propertize repo-str 'face 'gh-notify-notification-marked-face))
833 | (setq desc-str (propertize desc-str 'face 'gh-notify-notification-marked-face)))
834 | (unread
835 | (setq date-str (propertize date-str 'face 'gh-notify-notification-unread-face))
836 | (setq unread-str (propertize unread-str 'face 'gh-notify-notification-unread-face))
837 | (setq repo-str (propertize repo-str 'face 'gh-notify-notification-unread-face))
838 | (setq desc-str (propertize desc-str 'face 'gh-notify-notification-unread-face))))
839 |
840 | (concat unread-str " "
841 | date-str " "
842 | type-str " "
843 | state-str (if (string-equal state-str "") "" " ")
844 | repo-str " "
845 | reason-str " "
846 | desc-str))))
847 |
848 | (defun gh-notify-limit-notification (notification)
849 | "Limits NOTIFICATION by status.
850 | Limiting operation depends on `gh-notify-reason-limit', `gh-notify-type-limit'
851 | and `gh-notify--repo-limit'."
852 | ;; Ensure backward compatibility: convert old single-value format to list
853 | (unless (listp gh-notify-reason-limit)
854 | (setq-local gh-notify-reason-limit (list gh-notify-reason-limit)))
855 | (let ((repo-id (gh-notify-notification-repo-id notification)))
856 | ;; 3 pass filter: repo -> type -> reason
857 | (when
858 | ;; repo limits
859 | (and (or (member repo-id gh-notify--repo-limit)
860 | (not gh-notify--repo-limit))
861 | (not (member repo-id gh-notify-exclude-repo-limit)))
862 | ;;
863 | (and
864 | ;; state filter
865 | (if gh-notify--unread-limit
866 | (gh-notify-notification-unread notification)
867 | t)
868 | ;; type filters
869 | (or (eq (gh-notify-notification-type notification) gh-notify--type-limit)
870 | (not gh-notify--type-limit))
871 | ;; reason filters (layer 3 filter)
872 | ;; Check if notification matches any of the active reason filters
873 | (or (member :all gh-notify-reason-limit)
874 | (and (member :mark gh-notify-reason-limit)
875 | (gh-notify-notification-is-marked notification))
876 | (and (member :unread gh-notify-reason-limit)
877 | (gh-notify-notification-unread notification))
878 | (and (member :assign gh-notify-reason-limit)
879 | (eq (gh-notify-notification-reason notification) 'assign))
880 | (and (member :mention gh-notify-reason-limit)
881 | (eq (gh-notify-notification-reason notification) 'mention))
882 | (and (member :team_mention gh-notify-reason-limit)
883 | (eq (gh-notify-notification-reason notification) 'team_mention))
884 | (and (member :subscribed gh-notify-reason-limit)
885 | (eq (gh-notify-notification-reason notification) 'subscribed))
886 | (and (member :author gh-notify-reason-limit)
887 | (eq (gh-notify-notification-reason notification) 'author))
888 | (and (member :review-requested gh-notify-reason-limit)
889 | (eq (gh-notify-notification-reason notification) 'review_requested))
890 | (and (member :comment gh-notify-reason-limit)
891 | (eq (gh-notify-notification-reason notification) 'comment)))))))
892 |
893 | (defun gh-notify-filter-notification (notification)
894 | "Filters NOTIFICATION using a case-insensitive match on either URL or title."
895 | (let ((filter (gh-notify-active-filter)))
896 | (or (null filter)
897 | (let ((case-fold-search t)
898 | (url (gh-notify-notification-url notification))
899 | (title (gh-notify-notification-title notification)))
900 | (or
901 | (string-match (replace-regexp-in-string " " ".*" filter) url)
902 | (string-match (replace-regexp-in-string " " ".*" filter) title))))))
903 |
904 | (defun gh-notify-current-notification ()
905 | "Return notification at point or nil."
906 | (gethash (line-number-at-pos (point))
907 | gh-notify--visible-notifications))
908 |
909 | (defun gh-notify-goto-notification (notification)
910 | "Move point to NOTIFICATION if it is visible."
911 | (when-let ((line (gh-notify-notification-line notification)))
912 | (gh-notify--goto-line line)))
913 |
914 |
915 | ;;;
916 | ;;; Forge API
917 | ;;;
918 |
919 | (defun gh-notify--forge-get-notifications ()
920 | "Get all forge notifications."
921 | (let ((results '()))
922 | (when-let ((ns (forge--ls-notifications '(unread pending done))))
923 | (pcase-dolist (`(,key . ,grouped-ns) (seq-group-by (lambda (it) (oref it repository)) ns))
924 | (let ((repo (forge-get-repository (car grouped-ns))))
925 | (push (list repo grouped-ns) results))))
926 | results))
927 |
928 | (defun gh-notify-get-notifications ()
929 | "Return a record (alist) containing notification information.
930 |
931 | The alist contains (repo-id . notifications) pairs."
932 |
933 | (cl-loop with total-notification-count = 0
934 | with error-count = 0
935 | with repo-count = 0
936 | for repo-results in (gh-notify--forge-get-notifications)
937 | for repo-id = (car repo-results)
938 | for repo-notifications = (cadr repo-results)
939 | when repo-id do (cl-incf repo-count)
940 | when repo-notifications do
941 | (setq
942 | total-notification-count
943 | (+ total-notification-count
944 | (length repo-notifications)))
945 | when repo-notifications collect (cons repo-id repo-notifications)
946 | finally
947 | (progn
948 | (setq gh-notify--total-notification-count total-notification-count)
949 | (message "Retrieved %d notifications across %d repos%s"
950 | total-notification-count
951 | repo-count
952 | (if (> error-count 0)
953 | (format ", %d errors" error-count)
954 | "")))))
955 |
956 | (defun gh-notify-forge-refresh()
957 | "Refresh the Forge notification state."
958 | (interactive)
959 | (cl-assert (eq major-mode 'gh-notify-mode) t)
960 | ;; we fixed some of forge's all-or-nothing approach to notification updates
961 | ;; we want to routinely refresh without doing a full fetch, so instead we do
962 | ;; incremental refreshes based on the last known timestamp
963 |
964 | ;; ensure we always start from the most recent Magit/Forge db state
965 | (call-interactively #'gh-notify-retrieve-notifications)
966 | (forge--pull-notifications 'forge-github-repository "github.com" #'gh-notify-forge-refresh-cb))
967 |
968 | (defun gh-notify-forge-refresh-cb ()
969 | "Callback for Forge refresh."
970 | (message "Forge is refreshed!")
971 | (with-current-buffer gh-notify--current-buffer
972 | (call-interactively #'gh-notify-retrieve-notifications)))
973 |
974 | (defun gh-notify--insert-forge-obj (obj)
975 | "Insert/Replace a forge db object with OBJ."
976 | ;; replace the updated object ...
977 | (emacsql-with-transaction (forge-db)
978 | (closql-insert (forge-db) obj t)))
979 |
980 | (defun gh-notify--get-topic-state (type repo topic)
981 | "Get current topic state from forge db."
982 | (gh-notify--with-timing
983 | (pcase type
984 | ('issue
985 | (let ((issue (forge-get-issue topic)))
986 | (oref issue state)))
987 | ('pullreq
988 | (let ((pullreq (forge-get-pullreq topic)))
989 | (oref pullreq state))))))
990 |
991 | (defun gh-notify-set-notification-status (notification value)
992 | "Set NOTIFICATION status as VALUE"
993 | (when-let (topic-obj (gh-notify-notification-topic-obj notification))
994 | (when (oref topic-obj status)
995 | (oset topic-obj status value)
996 | ;; XXX: only oset the object now instead of replacing it in db
997 | ;; XXX: https://github.com/anticomputer/gh-notify/issues/19
998 | ;;(gh-notify--insert-forge-obj topic-obj)
999 | (when gh-notify-redraw-on-visit
1000 | (gh-notify-retrieve-notifications)))))
1001 | ;;;
1002 | ;;; Interactive
1003 | ;;;
1004 |
1005 | (defun gh-notify-ls-pullreqs-at-point (P)
1006 | "Navigate a list of active pull requests available for notification at point.
1007 | All pull requests on prefix P."
1008 | (interactive "P")
1009 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1010 | (when-let ((notification (gh-notify-current-notification)))
1011 | ;; build a closure for a pull topic callback
1012 | (let* ((repo-id (gh-notify-notification-repo-id notification))
1013 | (repo (gh-notify-notification-repo notification))
1014 | (P P)
1015 | (callback
1016 | (lambda ()
1017 | (with-local-quit
1018 | (let*
1019 | ((pullreqs
1020 | (forge--topic-collection
1021 | (forge--list-topics
1022 | (if P
1023 | (forge--topics-spec :type 'pullreq :active nil :state nil)
1024 | (forge--topics-spec :type 'pullreq :active t))
1025 | repo)))
1026 | (choice (completing-read
1027 | (format "%s visit pull request (%s): " repo-id (if P "all" "active"))
1028 | (mapcar #'car pullreqs) nil t)))
1029 | (unless (string-equal choice "")
1030 | (message "%s" choice)
1031 | ;; parse the number we selected back out
1032 | (let ((topic (and (string-match "^#\\([0-9]+\\) " choice)
1033 | (string-to-number (match-string 1 choice)))))
1034 | (with-demoted-errors "Warning: %S"
1035 | (when-let ((topic-obj (forge-get-pullreq repo topic)))
1036 | (forge-topic-setup-buffer topic-obj))))))))))
1037 | (forge--pull repo callback))))
1038 |
1039 | (defun gh-notify-ls-issues-at-point (P)
1040 | "Navigate a list of active issues available for notification at point.
1041 | All issues on prefix P."
1042 | (interactive "P")
1043 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1044 | (when-let ((notification (gh-notify-current-notification)))
1045 | ;; build a closure for a pull topic callback
1046 | (let* ((repo-id (gh-notify-notification-repo-id notification))
1047 | (repo (gh-notify-notification-repo notification))
1048 | (P P)
1049 | (callback
1050 | (lambda ()
1051 | (with-local-quit
1052 | (let*
1053 | ((issues
1054 | (forge--topic-collection
1055 | (forge--list-topics
1056 | (if P
1057 | (forge--topics-spec :type 'issue :active nil :state nil)
1058 | (forge--topics-spec :type 'issue :active t))
1059 | repo)))
1060 | (choice (completing-read
1061 | (format "%s visit issue (%s): " repo-id (if P "all" "active"))
1062 | (mapcar #'car issues) nil t)))
1063 | (unless (string-equal choice "")
1064 | (message "%s" choice)
1065 | ;; parse the number we selected back out
1066 | (let ((topic (and (string-match "^#\\([0-9]+\\) " choice)
1067 | (string-to-number (match-string 1 choice)))))
1068 | (with-demoted-errors "Warning: %S"
1069 | (when-let ((topic-obj (forge-get-issue repo topic)))
1070 | (forge-topic-setup-buffer topic-obj))))))))))
1071 | (forge--pull repo callback))))
1072 |
1073 | (defun gh-notify-display-state ()
1074 | "Show the current state for an issue or pull request notification."
1075 | (interactive)
1076 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1077 | (when-let ((notification (gh-notify-current-notification)))
1078 | (let ((type (gh-notify-notification-type notification))
1079 | (topic (gh-notify-notification-topic notification))
1080 | (repo (gh-notify-notification-repo notification)))
1081 | (message "state: %s" (gh-notify--get-topic-state type repo topic)))))
1082 |
1083 | (defun gh-notify-toggle-timing ()
1084 | "Toggle timing information on the header line."
1085 | (interactive)
1086 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1087 | (let ((timingp gh-notify-show-timing))
1088 | (setq-local gh-notify-show-timing (if timingp nil t))
1089 | (setq gh-notify--header-update t))
1090 | (force-mode-line-update))
1091 |
1092 | (defun gh-notify-toggle-url-view ()
1093 | "Toggle notifications being displayed as titles or URLs."
1094 | (interactive)
1095 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1096 | (let ((view gh-notify-default-view))
1097 | (setq-local gh-notify-default-view
1098 | (if (eq view :title) :url :title)))
1099 | (gh-notify--filter-notifications))
1100 |
1101 | (defun gh-notify-toggle-global-ts-sort ()
1102 | "Sort * by timestamp, or repo-block by timestamp (default)."
1103 | (interactive)
1104 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1105 | (setq-local gh-notify--global-ts-sort (not gh-notify--global-ts-sort))
1106 | (gh-notify--filter-notifications))
1107 |
1108 | (defun gh-notify-limit-marked ()
1109 | "Toggle showing marked notifications.
1110 | When toggled on, removes :all from the filter list if present.
1111 | Multiple reason filters can be active at once."
1112 | (interactive)
1113 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1114 | (if (member :mark gh-notify-reason-limit)
1115 | ;; Remove :mark from the list
1116 | (progn
1117 | (setq-local gh-notify-reason-limit (remove :mark gh-notify-reason-limit))
1118 | ;; If list is empty, add :all back
1119 | (when (null gh-notify-reason-limit)
1120 | (setq-local gh-notify-reason-limit '(:all))))
1121 | ;; Add :mark and remove :all
1122 | (setq-local gh-notify-reason-limit
1123 | (cons :mark (remove :all gh-notify-reason-limit))))
1124 | (gh-notify--filter-notifications))
1125 |
1126 | (defun gh-notify-limit-type-none ()
1127 | "Reset type limit to nil."
1128 | (interactive)
1129 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1130 | (setq-local gh-notify--type-limit nil)
1131 | (gh-notify--filter-notifications))
1132 |
1133 | (defun gh-notify-limit-issue (P)
1134 | "Only show issue notifications, remove limit on prefix P."
1135 | (interactive "P")
1136 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1137 | (if P (gh-notify-limit-type-none)
1138 | (progn
1139 | (setq-local gh-notify--type-limit 'issue)
1140 | (gh-notify--filter-notifications))))
1141 |
1142 | (defun gh-notify-limit-pr (P)
1143 | "Only show pull request notifications, remove limit on prefix P."
1144 | (interactive "P")
1145 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1146 | (if P (gh-notify-limit-type-none)
1147 | (progn
1148 | (setq-local gh-notify--type-limit 'pullreq)
1149 | (gh-notify--filter-notifications))))
1150 |
1151 | (defun gh-notify-limit-unread (P)
1152 | "Only show unread notifications, remove limit on prefix P."
1153 | (interactive "P")
1154 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1155 | (if P (setq-local gh-notify--unread-limit nil)
1156 | (setq-local gh-notify--unread-limit :unread))
1157 | (gh-notify--filter-notifications))
1158 |
1159 | (defun gh-notify-limit-assign ()
1160 | "Toggle showing notifications with reason: assign.
1161 | When toggled on, removes :all from the filter list if present.
1162 | Multiple reason filters can be active at once."
1163 | (interactive)
1164 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1165 | (if (member :assign gh-notify-reason-limit)
1166 | ;; Remove :assign from the list
1167 | (progn
1168 | (setq-local gh-notify-reason-limit (remove :assign gh-notify-reason-limit))
1169 | ;; If list is empty, add :all back
1170 | (when (null gh-notify-reason-limit)
1171 | (setq-local gh-notify-reason-limit '(:all))))
1172 | ;; Add :assign and remove :all
1173 | (setq-local gh-notify-reason-limit
1174 | (cons :assign (remove :all gh-notify-reason-limit))))
1175 | (gh-notify--filter-notifications))
1176 |
1177 | (defun gh-notify-limit-author ()
1178 | "Toggle showing notifications with reason: author.
1179 | When toggled on, removes :all from the filter list if present.
1180 | Multiple reason filters can be active at once."
1181 | (interactive)
1182 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1183 | (if (member :author gh-notify-reason-limit)
1184 | ;; Remove :author from the list
1185 | (progn
1186 | (setq-local gh-notify-reason-limit (remove :author gh-notify-reason-limit))
1187 | ;; If list is empty, add :all back
1188 | (when (null gh-notify-reason-limit)
1189 | (setq-local gh-notify-reason-limit '(:all))))
1190 | ;; Add :author and remove :all
1191 | (setq-local gh-notify-reason-limit
1192 | (cons :author (remove :all gh-notify-reason-limit))))
1193 | (gh-notify--filter-notifications))
1194 |
1195 | (defun gh-notify-limit-mention ()
1196 | "Toggle showing notifications with reason: mention.
1197 | When toggled on, removes :all from the filter list if present.
1198 | Multiple reason filters can be active at once."
1199 | (interactive)
1200 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1201 | (if (member :mention gh-notify-reason-limit)
1202 | ;; Remove :mention from the list
1203 | (progn
1204 | (setq-local gh-notify-reason-limit (remove :mention gh-notify-reason-limit))
1205 | ;; If list is empty, add :all back
1206 | (when (null gh-notify-reason-limit)
1207 | (setq-local gh-notify-reason-limit '(:all))))
1208 | ;; Add :mention and remove :all
1209 | (setq-local gh-notify-reason-limit
1210 | (cons :mention (remove :all gh-notify-reason-limit))))
1211 | (gh-notify--filter-notifications))
1212 |
1213 | (defun gh-notify-limit-team-mention ()
1214 | "Toggle showing notifications with reason: team_mention.
1215 | When toggled on, removes :all from the filter list if present.
1216 | Multiple reason filters can be active at once."
1217 | (interactive)
1218 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1219 | (if (member :team_mention gh-notify-reason-limit)
1220 | ;; Remove :team_mention from the list
1221 | (progn
1222 | (setq-local gh-notify-reason-limit (remove :team_mention gh-notify-reason-limit))
1223 | ;; If list is empty, add :all back
1224 | (when (null gh-notify-reason-limit)
1225 | (setq-local gh-notify-reason-limit '(:all))))
1226 | ;; Add :team_mention and remove :all
1227 | (setq-local gh-notify-reason-limit
1228 | (cons :team_mention (remove :all gh-notify-reason-limit))))
1229 | (gh-notify--filter-notifications))
1230 |
1231 | (defun gh-notify-limit-subscribed ()
1232 | "Toggle showing notifications with reason: subscribed.
1233 | When toggled on, removes :all from the filter list if present.
1234 | Multiple reason filters can be active at once."
1235 | (interactive)
1236 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1237 | (if (member :subscribed gh-notify-reason-limit)
1238 | ;; Remove :subscribed from the list
1239 | (progn
1240 | (setq-local gh-notify-reason-limit (remove :subscribed gh-notify-reason-limit))
1241 | ;; If list is empty, add :all back
1242 | (when (null gh-notify-reason-limit)
1243 | (setq-local gh-notify-reason-limit '(:all))))
1244 | ;; Add :subscribed and remove :all
1245 | (setq-local gh-notify-reason-limit
1246 | (cons :subscribed (remove :all gh-notify-reason-limit))))
1247 | (gh-notify--filter-notifications))
1248 |
1249 | (defun gh-notify-limit-comment ()
1250 | "Toggle showing notifications with reason: comment.
1251 | When toggled on, removes :all from the filter list if present.
1252 | Multiple reason filters can be active at once."
1253 | (interactive)
1254 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1255 | (if (member :comment gh-notify-reason-limit)
1256 | ;; Remove :comment from the list
1257 | (progn
1258 | (setq-local gh-notify-reason-limit (remove :comment gh-notify-reason-limit))
1259 | ;; If list is empty, add :all back
1260 | (when (null gh-notify-reason-limit)
1261 | (setq-local gh-notify-reason-limit '(:all))))
1262 | ;; Add :comment and remove :all
1263 | (setq-local gh-notify-reason-limit
1264 | (cons :comment (remove :all gh-notify-reason-limit))))
1265 | (gh-notify--filter-notifications))
1266 |
1267 | (defun gh-notify-limit-review-requested ()
1268 | "Toggle showing notifications with reason: review_requested.
1269 | When toggled on, removes :all from the filter list if present.
1270 | Multiple reason filters can be active at once."
1271 | (interactive)
1272 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1273 | (if (member :review-requested gh-notify-reason-limit)
1274 | ;; Remove :review-requested from the list
1275 | (progn
1276 | (setq-local gh-notify-reason-limit (remove :review-requested gh-notify-reason-limit))
1277 | ;; If list is empty, add :all back
1278 | (when (null gh-notify-reason-limit)
1279 | (setq-local gh-notify-reason-limit '(:all))))
1280 | ;; Add :review-requested and remove :all
1281 | (setq-local gh-notify-reason-limit
1282 | (cons :review-requested (remove :all gh-notify-reason-limit))))
1283 | (gh-notify--filter-notifications))
1284 |
1285 | (defun gh-notify-limit-repo (P)
1286 | "Only show notifications belonging to a specific repo, remove filter on prefix P."
1287 | (interactive "P")
1288 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1289 | (let* ((repos (vconcat (hash-table-keys gh-notify--repo-index))))
1290 | (if P
1291 | ;; delete a repo filter on prefix
1292 | (when gh-notify--repo-limit
1293 | (setq-local gh-notify--repo-limit
1294 | (delete (completing-read "repo filter remove: " (append gh-notify--repo-limit nil) nil t)
1295 | gh-notify--repo-limit)))
1296 | (let ((repo (completing-read "repo filter add: " (append repos nil) nil t)))
1297 | (unless (member repo gh-notify--repo-limit)
1298 | (push repo gh-notify--repo-limit)))))
1299 | (gh-notify--filter-notifications))
1300 |
1301 | (defun gh-notify-limit-none ()
1302 | "Remove all reason filters and show all notifications."
1303 | (interactive)
1304 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1305 | (unless (and (listp gh-notify-reason-limit)
1306 | (equal gh-notify-reason-limit '(:all)))
1307 | (setq-local gh-notify-reason-limit '(:all))
1308 | (gh-notify--filter-notifications)))
1309 |
1310 | (defun gh-notify-limit-repo-none ()
1311 | "Reset repo limit to default.."
1312 | (interactive)
1313 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1314 | (setq-local gh-notify--repo-limit gh-notify-default-repo-limit)
1315 | (gh-notify--filter-notifications))
1316 |
1317 | (defun gh-notify-copy-url ()
1318 | "Copy URL belonging to notification at point."
1319 | (interactive)
1320 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1321 | (when-let ((notification (gh-notify-current-notification)))
1322 | (let ((url (gh-notify-notification-url notification)))
1323 | (kill-new url)
1324 | (message "Copied: %s" url))))
1325 |
1326 | (defun gh-notify-set-status-pending (&optional notification)
1327 | "Set status of NOTIFICATION at point to pending."
1328 | (interactive)
1329 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1330 | (when-let ((notification (or notification (gh-notify-current-notification))))
1331 | (gh-notify-set-notification-status notification 'pending)))
1332 |
1333 | (defun gh-notify-set-status-done (&optional notification)
1334 | "Set status of NOTIFICATION at point to done."
1335 | (interactive)
1336 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1337 | (when-let ((notification (or notification (gh-notify-current-notification))))
1338 | (gh-notify-set-notification-status notification 'done)))
1339 |
1340 | (defun gh-notify-set-status-unread (&optional notification)
1341 | "Set status of NOTIFICATION at point to unread."
1342 | (interactive)
1343 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1344 | (when-let ((notification (or notification (gh-notify-current-notification))))
1345 | (gh-notify-set-notification-status notification 'unread)))
1346 |
1347 | (defun gh-notify-retrieve-notifications ()
1348 | "Retrieve and filter all Gh-Notify notifications.
1349 | This wipes and recreates all notification state in Emacs but keeps the current
1350 | filter and limit. It repositions point to the last notification at point when
1351 | possible."
1352 | (interactive)
1353 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1354 | (gh-notify--with-timing
1355 | (setq gh-notify--marked-notifications '())
1356 | (gh-notify--reindex-notifications (gh-notify-get-notifications))
1357 | (gh-notify--filter-notifications)))
1358 |
1359 | (defun gh-notify-reset-filter ()
1360 | "Kill current notification filter."
1361 | (interactive)
1362 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1363 | (setq gh-notify--active-filter nil)
1364 | (gh-notify--filter-notifications))
1365 |
1366 | ;; XXX move this to a macro, just testing for now with duplication
1367 | (defun gh-notify-marked-notifications-set-done ()
1368 | "Set all marked notifications to done."
1369 | (interactive)
1370 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1371 | (when gh-notify--marked-notifications
1372 | (gh-notify--with-timing
1373 | (cl-loop
1374 | for notification in gh-notify--marked-notifications do
1375 | (gh-notify-set-status-done notification))
1376 | (gh-notify-retrieve-notifications))))
1377 |
1378 | (defun gh-notify-marked-notifications-set-pending ()
1379 | "Set all marked notifications to pending."
1380 | (interactive)
1381 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1382 | (when gh-notify--marked-notifications
1383 | (gh-notify--with-timing
1384 | (cl-loop
1385 | for notification in gh-notify--marked-notifications do
1386 | (gh-notify-set-status-pending notification))
1387 | (gh-notify-retrieve-notifications))))
1388 |
1389 | (defun gh-notify-marked-notifications-set-unread ()
1390 | "Set all marked notifications to unread."
1391 | (interactive)
1392 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1393 | (when gh-notify--marked-notifications
1394 | (gh-notify--with-timing
1395 | (cl-loop
1396 | for notification in gh-notify--marked-notifications do
1397 | (gh-notify-set-status-unread notification))
1398 | (gh-notify-retrieve-notifications))))
1399 |
1400 | (defun gh-notify-mark-notification (&optional notification)
1401 | "Mark NOTIFICATION at point."
1402 | (interactive)
1403 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1404 | (let ((move-forward (if notification nil t)))
1405 | (when-let ((notification (or notification (gh-notify-current-notification))))
1406 | (unless (gh-notify-notification-is-marked notification)
1407 | (setf (gh-notify-notification-is-marked notification) t)
1408 | (push notification gh-notify--marked-notifications)
1409 | (let ((inhibit-read-only t)
1410 | (point (point)))
1411 | (unwind-protect
1412 | (gh-notify--render-notification notification)
1413 | (goto-char point))))
1414 | (when move-forward (forward-line)))))
1415 |
1416 | (defun gh-notify-unmark-notification (&optional notification)
1417 | "Unmark NOTIFICATION at point."
1418 | (interactive)
1419 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1420 | (let ((move-forward (if notification nil t)))
1421 | (when-let ((notification (or notification (gh-notify-current-notification))))
1422 | (when (gh-notify-notification-is-marked notification)
1423 | (setf (gh-notify-notification-is-marked notification) nil)
1424 | (delete notification gh-notify--marked-notifications)
1425 | (let ((inhibit-read-only t)
1426 | (point (point)))
1427 | (unwind-protect
1428 | (gh-notify--render-notification notification)
1429 | (goto-char point))))
1430 | (when move-forward (forward-line)))))
1431 |
1432 | (defsubst gh-notify-do-visible-notifications (function)
1433 | "Call FUNCTION once for each visible notification.
1434 | Passes notification as an argument."
1435 | (mapc function
1436 | (if (region-active-p)
1437 | (save-excursion
1438 | (let ((begin (region-beginning))
1439 | (end (region-end)))
1440 | (goto-char begin)
1441 | (cl-loop for pos = (point) while (< pos end)
1442 | collect (gh-notify-current-notification)
1443 | do (forward-line))))
1444 | (hash-table-values gh-notify--visible-notifications))))
1445 |
1446 | (defun gh-notify-mark-all-notifications ()
1447 | "Mark all notifications currently visible in Emacs.
1448 | If there is a region, only mark notifications in region."
1449 | (interactive)
1450 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1451 | (gh-notify-do-visible-notifications #'gh-notify-mark-notification))
1452 |
1453 | (defun gh-notify-unmark-all-notifications ()
1454 | "Unmark all notifications currently visible in Emacs.
1455 | If there is a region, only unmark notifications in region."
1456 | (interactive)
1457 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1458 | (gh-notify-do-visible-notifications #'gh-notify-unmark-notification))
1459 |
1460 | (defun gh-notify-forge-visit-repo-at-point ()
1461 | "Visit repo at point."
1462 | (interactive)
1463 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1464 | (when-let ((current-notification (gh-notify-current-notification)))
1465 | (let* ((repo (gh-notify-notification-repo current-notification)))
1466 | (if repo
1467 | (let ((worktree (oref repo worktree)))
1468 | (if (and worktree (file-directory-p worktree))
1469 | (magit-status-setup-buffer worktree)
1470 | (forge-list-issues (oref repo id))))
1471 | (message "No forge github repo available at point!")))))
1472 |
1473 | (defun gh-notify-visit-notification (P)
1474 | "Attempt to visit notification at point in some sane way.
1475 | Browse issue or PR on prefix P."
1476 | (interactive "P")
1477 | (cl-assert (eq major-mode 'gh-notify-mode) t)
1478 | (when-let ((current-notification (gh-notify-current-notification)))
1479 | (let* ((repo-id (gh-notify-notification-repo-id current-notification))
1480 | (repo (gh-notify-notification-repo current-notification))
1481 | (topic (gh-notify-notification-topic current-notification))
1482 | (number (gh-notify-notification-number current-notification))
1483 | (type (gh-notify-notification-type current-notification))
1484 | (title (gh-notify-notification-title current-notification)))
1485 | (if P
1486 | ;; browse url for issue or pull request on prefix
1487 | (gh-notify-browse-notification repo-id type number)
1488 | ;; handle through magit forge otherwise
1489 |
1490 | ;; important: we want to re-render on read/unread state before switching
1491 | ;; buffers, that's because we do an auto-magic point reposition based on
1492 | ;; the last notification state, but on a buffer switch, the active point
1493 | ;; is lost in the middle of this logic, this doesn't "break" anything, but
1494 | ;; it can result in a lagging point, so take care of all the state rendering
1495 | ;; first, and THEN trigger the buffer switch
1496 |
1497 | (pcase type
1498 | ((or 'issue 'pullreq 'discussion)
1499 | (gh-notify-set-notification-status current-notification 'pending)
1500 | (with-demoted-errors "Warning visiting topic: %S"
1501 | (if-let ((topic-obj (gh-notify-notification-topic-obj current-notification)))
1502 | (progn
1503 | (forge-topic-setup-buffer topic-obj)
1504 | ;; Fetch latest topic data
1505 | (forge--pull-topic repo topic-obj))
1506 | (message "Topic %s not in database, try running forge-pull" number))))
1507 | ('commit
1508 | (message "Commit notifications not yet supported"))
1509 | (_
1510 | (message "Unknown notification type: %s - %s" type title)))))))
1511 |
1512 | (defun gh-notify-browse-notification (repo-id type number)
1513 | "Browse to a TOPIC of TYPE on GitHub REPO-ID."
1514 | (if (member type '(issue pullreq discussion))
1515 | (let ((url (format "https://github.com/%s/%s/%s"
1516 | repo-id
1517 | (pcase type
1518 | ('discussion "discussions")
1519 | ('issue "issues")
1520 | ('pullreq "pull"))
1521 | number)))
1522 | (browse-url url))
1523 | (message "Can't browse to this notification!")))
1524 |
1525 | ;;;###autoload
1526 | (defun gh-notify ()
1527 | "Magit/Forge notification juggling."
1528 | (interactive)
1529 | (let ((buf (get-buffer-create "*github-notifications*")))
1530 | (switch-to-buffer buf)
1531 | (unless (eq major-mode 'gh-notify-mode)
1532 | (gh-notify-mode))))
1533 |
1534 | (provide 'gh-notify)
1535 | ;;; gh-notify.el ends here
1536 |
--------------------------------------------------------------------------------