Commit 476a2c55bd49

Vincent Demeester <vincent@sbr.pm>
2025-02-20 16:44:05
feat: implement GitHub notifications mode for Emacs
This adds a new major mode `github-notifications-mode` that allows users to view and manage GitHub notifications directly within Emacs. The mode integrates with the GitHub CLI (gh) to fetch notifications data and display it in a tabulated list format. Key features: - Fetch and display GitHub notifications in a dedicated buffer - Mark notifications as read - Open notifications in browser - Auto-refresh capabilities - Custom faces for unread notifications - Tabulated view with sortable columns Very *not* optimized. Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 86c86bc
Changed files (1)
tools/emacs/lisp/gh-notifications-mode.el
@@ -0,0 +1,236 @@
+;;; github-notifications-gh.el --- Display GitHub notifications in Emacs using gh CLI -*- lexical-binding: t -*-
+
+;; Author: Your Name
+;; Version: 1.0
+;; Package-Requires: ((emacs "27.1"))
+
+;;; Commentary:
+;; This package provides functionality to fetch and display GitHub notifications
+;; in a dedicated buffer using the GitHub CLI (gh) and tabulated-list-mode.
+;; Make sure you have gh installed and authenticated before using this package.
+
+;;; For pull-request, fetch the diff from the diff_url field
+;;; For CI status, use the statuses_url
+
+;;; Code:
+
+(require 'json)
+(require 'tabulated-list)
+(require 'diff-mode)
+
+(defgroup github-notifications nil
+  "GitHub notifications in Emacs."
+  :group 'applications)
+
+(defcustom github-notifications-refresh-interval 300
+  "Number of seconds between automatic refresh of notifications."
+  :type 'integer
+  :group 'github-notifications)
+
+(defface github-notifications-unread-face
+  '((t :weight bold))
+  "Face for unread notifications."
+  :group 'github-notifications)
+
+(defvar github-notifications-buffer-name "*GitHub Notifications*"
+  "Name of the buffer for displaying GitHub notifications.")
+
+(defvar github-notifications-mode-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map (kbd "g") 'github-notifications-refresh)
+    (define-key map (kbd "m") 'github-notifications-mark-read)
+    (define-key map (kbd "RET") 'github-notifications-open-at-point)
+    map)
+  "Keymap for GitHub notifications buffer.")
+
+(define-derived-mode github-notifications-mode tabulated-list-mode "GitHub-Notifications"
+  "Major mode for displaying GitHub notifications."
+  (setq tabulated-list-format
+        [("Status" 6 t)
+         ("CI" 8 t)
+         ("Repository" 30 t)
+         ("Type" 15 t)
+         ("Title" 60 t)
+         ("Updated" 20 t)])
+  (setq tabulated-list-sort-key '("Updated" . t))
+  (setq tabulated-list-padding 2)
+  (tabulated-list-init-header))
+
+(defun github-notifications--ensure-gh ()
+  "Ensure gh command line tool is available."
+  (unless (executable-find "gh")
+    (error "GitHub CLI (gh) not found. Please install it first")))
+
+(defun github-notifications--parse-json (json-string)
+  "Parse JSON-STRING into Lisp objects."
+  (json-read-from-string json-string))
+
+(defun github-notifications--format-time (time-string)
+  "Format TIME-STRING to a human-readable format."
+  (format-time-string
+   "%Y-%m-%d %H:%M"
+   (date-to-time time-string)))
+
+(defun github-notifications--format-list-entry (notification)
+  "Format a NOTIFICATION as a tabulated list entry."
+  (let-alist notification
+    (let* ((type (or .subject.type "Unknown"))
+           (id .id)
+           (url .subject.url)
+           (repo .repository.full_name)
+           (status (propertize "●" 'face 'github-notifications-unread-face))
+           (ci-status
+            (if (string= type "PullRequest")
+                (github-notifications--format-ci-status
+                 (github-notifications--get-pr-statuses
+                  repo
+                  (github-notifications--get-pr-number url)))
+              " ")))
+      (list id
+            (vector
+             status
+             ci-status
+             repo
+             type
+             (propertize .subject.title
+                        'notification-id id
+                        'notification-url url
+                        'notification-type type
+                        'repo repo)
+             (github-notifications--format-time .updated_at))))))
+
+(defun github-notifications--get-pr-number (url)
+  "Extract pull request number from URL."
+  (when (string-match "/pulls/\\([0-9]+\\)" url)
+    (match-string 1 url)))
+
+(defun github-notifications-fetch ()
+  "Fetch notifications using gh CLI."
+  (github-notifications--ensure-gh)
+  (let* ((json-string
+          (with-temp-buffer
+            (unless (= 0 (call-process "gh" nil t nil
+                                     "api"
+                                     "-H" "Accept: application/vnd.github+json"
+                                     "/notifications"))
+              (error "Failed to fetch notifications"))
+            (buffer-string)))
+         (notifications (github-notifications--parse-json json-string)))
+    (github-notifications--display notifications)))
+
+(defun github-notifications--display (notifications)
+  "Display NOTIFICATIONS in the buffer."
+  (with-current-buffer (get-buffer-create github-notifications-buffer-name)
+    (github-notifications-mode)
+    (setq tabulated-list-entries
+          (mapcar #'github-notifications--format-list-entry notifications))
+    (tabulated-list-print t)))
+
+(defun github-notifications-refresh ()
+  "Refresh the notifications buffer."
+  (interactive)
+  (github-notifications-fetch))
+
+(defun github-notifications-mark-read ()
+  "Mark notification at point as read."
+  (interactive)
+  (when-let* ((entry (tabulated-list-get-entry))
+              (title-col (aref entry 3))
+              (id (get-text-property 0 'notification-id title-col)))
+    (github-notifications--ensure-gh)
+    (with-temp-buffer
+      (unless (= 0 (call-process "gh" nil t nil
+                                "api"
+                                "-X" "PATCH"
+                                (format "/notifications/threads/%s" id)))
+        (error "Failed to mark notification as read")))
+    (github-notifications-refresh)))
+
+(defun github-notifications-open-at-point ()
+  "Open the notification at point in a web browser."
+  (interactive)
+  (when-let* ((entry (tabulated-list-get-entry))
+              (title-col (aref entry 3))
+              (url (get-text-property 0 'notification-url title-col)))
+    (let ((web-url (replace-regexp-in-string
+                   "api\\.github\\.com/repos"
+                   "github.com"
+                   (replace-regexp-in-string
+                    "/pulls/"
+                    "/pull/"
+                    url))))
+      (browse-url web-url))))
+
+(defun github-notifications--get-pr-statuses (repo pr-number)
+  "Get CI statuses for a PR using the GitHub API."
+  (when (and repo pr-number)
+    (with-temp-buffer
+      ;; Get PR data to obtain statuses_url
+      (call-process "gh" nil t nil
+                   "api"
+                   (format "/repos/%s/pulls/%s" repo pr-number))
+      (let* ((pr-data (github-notifications--parse-json (buffer-string)))
+	     (statuses-url (alist-get 'statuses_url pr-data)))
+        ;; Get the actual statuses
+        (when statuses-url
+          (erase-buffer)
+          (call-process "gh" nil t nil
+                       "api"
+                       statuses-url)
+          (let* ((statuses (github-notifications--parse-json (buffer-string)))
+                 (total (length statuses))
+                 (successes (seq-count
+                           (lambda (status)
+                             (string= (alist-get 'state status) "success"))
+                           statuses))
+                 (failures (seq-count
+                          (lambda (status)
+                            (string= (alist-get 'state status) "failure"))
+                          statuses))
+                 (pendings (seq-count
+                          (lambda (status)
+                            (string= (alist-get 'state status) "pending"))
+                          statuses)))
+            (list :total total
+                  :successes successes
+                  :failures failures
+                  :pendings pendings)))))))
+
+(defun github-notifications--format-ci-status (statuses)
+  "Format CI status with appropriate face and count information."
+  (if (null statuses)
+      (propertize "?" 'face '(:foreground "gray"))
+    (let* ((total (plist-get statuses :total))
+           (successes (plist-get statuses :successes))
+           (failures (plist-get statuses :failures))
+           (pendings (plist-get statuses :pendings))
+           (indicator
+            (cond
+             ((> failures 0) "✗")
+             ((> pendings 0) "○")
+             ((= successes total) "✓")
+             (t "?")))
+           (face
+            (cond
+             ((> failures 0) '(:foreground "red"))
+             ((> pendings 0) '(:foreground "yellow"))
+             ((= successes total) '(:foreground "green"))
+             (t '(:foreground "gray"))))
+           (count-str (format "%d/%d" successes total)))
+      (concat
+       (propertize indicator 'face face)
+       " "
+       (propertize count-str 'face face)))))
+
+;;;###autoload
+(defun github-notifications ()
+  "Display GitHub notifications in a buffer."
+  (interactive)
+  (github-notifications--ensure-gh)
+  (let ((buffer (get-buffer-create github-notifications-buffer-name)))
+    (with-current-buffer buffer
+      (github-notifications-fetch))
+    (switch-to-buffer buffer)))
+
+(provide 'github-notifications-gh)
+;;; github-notifications-gh.el ends here