├── 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 | --------------------------------------------------------------------------------