fedora-csb-system-manager
1;;; github-notifications-gh.el --- Display GitHub notifications in Emacs using gh CLI -*- lexical-binding: t -*-
2
3;; Author: Your Name
4;; Version: 1.0
5;; Package-Requires: ((emacs "27.1"))
6
7;;; Commentary:
8;; This package provides functionality to fetch and display GitHub notifications
9;; in a dedicated buffer using the GitHub CLI (gh) and tabulated-list-mode.
10;; Make sure you have gh installed and authenticated before using this package.
11
12;;; For pull-request, fetch the diff from the diff_url field
13;;; For CI status, use the statuses_url
14
15;;; Code:
16
17(require 'json)
18(require 'tabulated-list)
19(require 'diff-mode)
20
21(defgroup github-notifications nil
22 "GitHub notifications in Emacs."
23 :group 'applications)
24
25(defcustom github-notifications-refresh-interval 300
26 "Number of seconds between automatic refresh of notifications."
27 :type 'integer
28 :group 'github-notifications)
29
30(defface github-notifications-unread-face
31 '((t :weight bold))
32 "Face for unread notifications."
33 :group 'github-notifications)
34
35(defvar github-notifications-buffer-name "*GitHub Notifications*"
36 "Name of the buffer for displaying GitHub notifications.")
37
38(defvar github-notifications-mode-map
39 (let ((map (make-sparse-keymap)))
40 (define-key map (kbd "g") 'github-notifications-refresh)
41 (define-key map (kbd "m") 'github-notifications-mark-read)
42 (define-key map (kbd "c") 'github-notifications-comment-on-pr)
43 (define-key map (kbd "d") 'github-notifications-mark-done)
44 (define-key map (kbd "a") 'github-notifications-approve-pr)
45 (define-key map (kbd "w") 'github-notifications-copy-url)
46 (define-key map (kbd "r") 'github-notifications-request-changes-on-pr)
47 (define-key map (kbd "v") 'github-notifications-show-details)
48 (define-key map (kbd "RET") 'github-notifications-open-at-point)
49 ;; m for mark
50 map)
51 "Keymap for GitHub notifications buffer.")
52
53(defvar github-notifications-detail-buffer-name "*GitHub Notification Detail*"
54 "Name of the buffer for displaying GitHub notification details.")
55
56(defvar github-notifications-detail-mode-map
57 (let ((map (make-sparse-keymap)))
58 (define-key map (kbd "q") 'quit-window)
59 map)
60 "Keymap for GitHub notification detail buffer.")
61
62(define-derived-mode github-notifications-detail-mode special-mode "GitHub-Notification-Detail"
63 "Major mode for displaying GitHub notification details."
64 (setq buffer-read-only t))
65
66(defvar github-notifications--process-buffer "*github-notifications-process*"
67 "Buffer for GitHub notifications processes.")
68
69(defun github-notifications--call-process-async (callback _buf name &rest args)
70 "Call gh process asynchronously with ARGS and CALLBACK when done.
71Creates a temporary buffer for the process output."
72 (let ((temp-buffer (generate-new-buffer (format " *gh-%s*" name))))
73 (setenv "TERM" "dumb")
74 (setenv "CLICOLOR" "0")
75 (setenv "PAGER" "cat")
76 (make-process
77 :name name
78 :buffer temp-buffer
79 :command (cons "gh" args)
80 :sentinel (lambda (process _event)
81 (unwind-protect
82 (when (eq (process-status process) 'exit)
83 (if (= (process-exit-status process) 0)
84 (with-current-buffer (process-buffer process)
85 (funcall callback (buffer-string)))
86 (message "GitHub CLI process failed")))
87 (kill-buffer temp-buffer))))))
88
89(define-derived-mode github-notifications-mode tabulated-list-mode "GitHub-Notifications"
90 "Major mode for displaying GitHub notifications."
91 (setq tabulated-list-format
92 [("Status" 6 t)
93 ("CI" 8 t)
94 ("Repository" 30 t)
95 ("Type" 15 t)
96 ("Title" 60 t)
97 ("Updated" 20 t)])
98 (setq tabulated-list-sort-key '("Updated" . t))
99 (setq tabulated-list-padding 2)
100 (tabulated-list-init-header))
101
102(defun github-notifications--ensure-gh ()
103 "Ensure gh command line tool is available."
104 (unless (executable-find "gh")
105 (error "GitHub CLI (gh) not found. Please install it first")))
106
107(defun github-notifications--parse-json (json-string)
108 "Parse JSON-STRING into Lisp objects."
109 (json-read-from-string json-string))
110
111(defun github-notifications--format-time (time-string)
112 "Format TIME-STRING to a human-readable format."
113 (format-time-string
114 "%Y-%m-%d %H:%M"
115 (date-to-time time-string)))
116
117(defun github-notifications--format-list-entry (notification)
118 "Format a NOTIFICATION as a tabulated list entry."
119 (let-alist notification
120 (list .id
121 (vector
122 (github-notifications--format-unread .unread)
123 (if (string= .type "PullRequest")
124 (github-notifications--format-ci-status .ci_status)
125 " ")
126 ;; (if (string= .type "PullRequest")
127 ;; (or .ci_status "?")
128 ;; "")
129 .repo
130 .type
131 (propertize .title
132 'notification-id .id
133 'notification-url .url ; Add this line
134 'github-notifications--copy-url .url
135 'notification-type .type
136 'repo .repo)
137 (github-notifications--format-time .updated)))))
138
139(defun github-notifications-fetch ()
140 "Fetch notifications using gh CLI asynchronously."
141 (github-notifications--ensure-gh)
142 (github-notifications--call-process-async
143 (lambda (output)
144 (let* ((raw-notifications (github-notifications--parse-json output))
145 (normalized-notifications
146 (mapcar #'github-notifications--normalize-notification raw-notifications)))
147 ;; For each PR notification, fetch its status
148 (dolist (notif normalized-notifications)
149 (when (and (equal (alist-get 'type notif) "PullRequest"))
150 (github-notifications--get-pr-statuses notif))
151 ;; FIXME there is a bug here, it share the same thing
152 (github-notifications--display normalized-notifications))))
153 (get-buffer-create github-notifications--process-buffer)
154 "github-notifications"
155 "api"
156 "-H" "Accept: application/vnd.github+json"
157 "/notifications?all=true"))
158
159(defun github-notifications--normalize-notification (notif)
160 "Convert raw notification to normalized format."
161 (let-alist notif
162 `((id . ,.id)
163 (type . ,.subject.type)
164 (title . ,.subject.title)
165 (repo . ,.repository.full_name)
166 (url . ,.subject.url)
167 (updated . ,.updated_at)
168 (unread . ,.unread)
169 (ci_status . nil)
170 (statuses_url . ,.subject.statuses_url))))
171
172(defun github-notifications--display (notifications)
173 "Display NOTIFICATIONS in the buffer."
174 (with-current-buffer (get-buffer-create github-notifications-buffer-name)
175 (github-notifications-mode)
176 (setq tabulated-list-entries
177 (mapcar #'github-notifications--format-list-entry notifications))
178 (tabulated-list-print t)))
179
180(defun github-notifications-refresh ()
181 "Refresh the notifications buffer."
182 (interactive)
183 (github-notifications-fetch))
184
185(defun github-notifications-mark-read ()
186 "Mark notification at point as read."
187 (interactive)
188 (when-let* ((entry (github-notifications--get-entry-data))
189 (id (nth 4 entry)))
190 (github-notifications--ensure-gh)
191 (with-temp-buffer
192 (unless (= 0 (call-process "gh" nil t nil
193 "api"
194 "-X" "PATCH"
195 (format "/notifications/threads/%s" id)))
196 (error "Failed to mark notification as read")))
197 (github-notifications-refresh)))
198
199(defun github-notifications-mark-done ()
200 "Mark notification at point as done."
201 (interactive)
202 (when-let* ((entry (github-notifications--get-entry-data))
203 (id (nth 4 entry)))
204 (github-notifications--ensure-gh)
205 (with-temp-buffer
206 (unless (= 0 (call-process "gh" nil t nil
207 "api"
208 "-X" "DELETE"
209 (format "/notifications/threads/%s" id)))
210 (error "Failed to mark notification as done")))
211 (github-notifications-refresh)))
212
213(defun github-notifications--copy-url ()
214 "Copy the URL of the notification item at point."
215 (interactive)
216 (let* ((entry (github-notifications--get-entry-data))
217 (api-url (nth 3 entry))
218 (url (replace-regexp-in-string
219 "api\\.github\\.com/repos"
220 "github.com"
221 (replace-regexp-in-string
222 "/pulls/"
223 "/pull/"
224 api-url))))
225 (kill-new url)))
226
227(defun github-notifications-open-at-point ()
228 "Open the notification at point in a web browser."
229 (interactive)
230 (when-let* ((entry (tabulated-list-get-entry))
231 (title-col (aref entry 3))
232 (url (get-text-property 0 'notification-url title-col)))
233 (let ((web-url (replace-regexp-in-string
234 "api\\.github\\.com/repos"
235 "github.com"
236 (replace-regexp-in-string
237 "/pulls/"
238 "/pull/"
239 url))))
240 (browse-url web-url))))
241
242(defun github-notifications--get-entry-data ()
243 "Get PR data from current entry."
244 (let* ((entry (tabulated-list-get-entry))
245 (id (aref entry 3))
246 (title-cell (aref entry 4))
247 (repo (get-text-property 0 'repo title-cell))
248 (url (get-text-property 0 'notification-url title-cell))
249 (notification-id (get-text-property 0 'notification-id title-cell))
250 (pr-number (github-notifications--get-pr-number url)))
251 (list id repo pr-number url notification-id)))
252
253(defun github-notifications-comment-on-pr ()
254 "Add a comment to the pull request at point."
255 (interactive)
256 (let* ((pr-data (github-notifications--get-entry-data))
257 (repo (nth 1 pr-data))
258 (pr-number (nth 2 pr-data))
259 (comment (read-string "Comment: "))),
260 (when (and repo pr-number (not (string-empty-p comment)))
261 (let ((default-directory (make-temp-file "gh-pr" t)))
262 (call-process "gh" nil "*github-notifications process*" nil
263 "pr" "comment"
264 pr-number
265 "--body" comment
266 "--repo" repo)
267 (message "Comment posted successfully")))))
268
269(defun github-notifications-request-changes-on-pr ()
270 "Add a comment to the pull request at point."
271 (interactive)
272 (let* ((pr-data (github-notifications--get-entry-data))
273 (repo (nth 1 pr-data))
274 (pr-number (nth 2 pr-data))
275 (comment (read-string "Comment: ")))
276 (when (and repo pr-number (not (string-empty-p comment)))
277 (let ((default-directory (make-temp-file "gh-pr" t)))
278 (call-process "gh" nil "*github-notifications process*" nil
279 "pr" "review"
280 pr-number
281 "--body" comment
282 "--request-changes"
283 "--repo" repo)
284 (message "Comment posted successfully")))))
285
286(defun github-notifications-approve-pr (&optional comment)
287 "Approve the pull request at point with an optional comment."
288 (interactive
289 (list (read-string "Approval comment (optional): ")))
290 (let* ((pr-data (github-notifications--get-entry-data))
291 (repo (nth 1 pr-data))
292 (pr-number (nth 2 pr-data))
293 (args (list "pr" "review"
294 pr-number
295 "--approve"
296 "--repo" repo)))
297 (when (and repo pr-number)
298 (when (and comment (not (string-empty-p comment)))
299 (setq args (append args (list "--body" comment))))
300 (let ((default-directory (make-temp-file "gh-pr" t)))
301 (apply #'call-process "gh" nil "*github-notifications process*" nil args)
302 (message "PR approved successfully")))))
303
304(defun github-notifications--get-pr-statuses (notification)
305 "Get CI statuses for a PR using the GitHub GraphQL API."
306 (let* ((url (alist-get 'url notification))
307 (repo (alist-get 'repo notification))
308 (pr-number(github-notifications--get-pr-number url)))
309 (when (and repo pr-number)
310 (with-temp-buffer
311 (github-notifications--call-process-async
312 (lambda (output)
313 (let* ((response (github-notifications--parse-json (buffer-string)))
314 (contexts (thread-last response
315 (alist-get 'data)
316 (alist-get 'repository)
317 (alist-get 'pullRequest)
318 (alist-get 'commits)
319 (alist-get 'nodes)
320 (seq-first)
321 (alist-get 'commit)
322 (alist-get 'statusCheckRollup)
323 (alist-get 'contexts)
324 (alist-get 'nodes)))
325 (statuses (seq-map
326 (lambda (ctx)
327 (let ((state (or (alist-get 'state ctx)
328 (alist-get 'conclusion ctx))))
329 (cond
330 ((member state '("SUCCESS" "success" "COMPLETED")) "success")
331 ((member state '("FAILURE" "failure" "ERROR" "error")) "failure")
332 (t "pending"))))
333 contexts))
334 (total (length statuses))
335 (successes (seq-count (lambda (s) (string= s "success")) statuses))
336 (failures (seq-count (lambda (s) (string= s "failure")) statuses))
337 (pendings (seq-count (lambda (s) (string= s "pending")) statuses))
338 (ci-status (list :total total
339 :successes successes
340 :failures failures
341 :pendings pendings)))
342 (setf (alist-get 'ci_status notification) ci-status)))
343 (get-buffer-create (format "*github-notifications-%s-%s-process*" repo pr-number))
344 (format "github-notifications-%s-%s" repo pr-number)
345 "api" "graphql" "-f"
346 (format "query=%s"
347 (github-notifications--make-graphql-query repo pr-number)))))))
348
349(defun github-notifications--format-ci-status (statuses)
350 "Format CI status with appropriate face and count information."
351 (if (null statuses)
352 (propertize "?" 'face '(:foreground "gray"))
353 (let* ((total (plist-get statuses :total))
354 (successes (plist-get statuses :successes))
355 (failures (plist-get statuses :failures))
356 (pendings (plist-get statuses :pendings))
357 (indicator
358 (cond
359 ((> failures 0) "✗")
360 ((> pendings 0) "○")
361 ((= successes total) "✓")
362 (t "?")))
363 (face
364 (cond
365 ((> failures 0) '(:foreground "red"))
366 ((> pendings 0) '(:foreground "orange"))
367 ((= successes total) '(:foreground "green"))
368 (t '(:foreground "gray"))))
369 (count-str (format "%d/%d" successes total)))
370 (concat
371 (propertize indicator 'face face)
372 " "
373 (propertize count-str 'face face)))))
374
375(defun github-notifications--make-graphql-query (repo pr-number)
376 "Create a GraphQL query for PR status checks."
377 (format "query {
378 repository(owner: \"%s\", name: \"%s\") {
379 pullRequest(number: %s) {
380 commits(last: 1) {
381 nodes {
382 commit {
383 statusCheckRollup {
384 state
385 contexts(first: 100) {
386 nodes {
387 ... on StatusContext {
388 state
389 context
390 }
391 ... on CheckRun {
392 status
393 conclusion
394 name
395 }
396 }
397 }
398 }
399 }
400 }
401 }
402 }
403 }
404 }"
405 (car (split-string repo "/"))
406 (cadr (split-string repo "/"))
407 pr-number))
408
409(defun github-notifications--format-unread (unread)
410 "Return a propertized string to showcase the status of the notifications"
411 ;; (cond ((unread) (propertize "●" 'face 'github-notifications-unread-face))
412 ;; ((not unread) (propertize "○" 'face 'github-notifications-unread-face))
413 ;; (t (propertize "?" 'face 'github-notifications-unread-face)))
414 (cond (unread (propertize "●" 'face 'github-notifications-unread-face))
415 ((not unread) (propertize "○" 'face 'github-notifications-unread-face))
416 (t (propertize "?" 'face 'github-notifications-unread-face))))
417
418(defun github-notifications--get-pr-number (url)
419 "Extract pull request number from URL."
420 (when (string-match "/pulls/\\([0-9]+\\)" url)
421 (match-string 1 url)))
422
423(defun github-notifications-show-details ()
424 "Show detailed view of the notification at point."
425 (interactive)
426 (when-let* ((entry (tabulated-list-get-entry))
427 (title-cell (aref entry 4))
428 (type (get-text-property 0 'notification-type title-cell))
429 (repo (get-text-property 0 'repo title-cell))
430 (url (get-text-property 0 'github-notifications--copy-url title-cell)))
431 (let ((buffer (get-buffer-create github-notifications-detail-buffer-name)))
432 (with-current-buffer buffer
433 (let ((inhibit-read-only t))
434 (erase-buffer)
435 (github-notifications-detail-mode)
436 (cond
437 ((string= type "PullRequest")
438 (github-notifications--show-pr-details repo url))
439 ((string= type "Issue")
440 (github-notifications--show-issue-details repo url))
441 (t
442 (insert "No detailed view available for this notification type."))))
443 (goto-char (point-min)))
444 (display-buffer buffer))))
445
446(defun github-notifications--show-pr-details (repo url)
447 "Show pull request details for REPO and URL."
448 (let ((pr-number (github-notifications--get-pr-number url)))
449 (when pr-number
450 (insert (format "=== Pull Request #%s ===\n\n" pr-number))
451 ;; Fetch PR details
452 (github-notifications--call-process-async
453 (lambda (output)
454 (let ((inhibit-read-only t))
455 (save-excursion
456 (goto-char (point-min))
457 (forward-line 2)
458 (let* ((pr-data (github-notifications--parse-json output)))
459 (insert (format "Title: %s\n" (alist-get 'title pr-data)))
460 (insert (format "State: %s\n" (alist-get 'state pr-data)))
461 (insert (format "\nDescription:\n%s\n" (alist-get 'body pr-data)))))))
462 nil
463 "pr-details"
464 "pr" "view" pr-number "--json" "title,body,state" "--repo" repo)
465
466 ;; Fetch and display diff
467 (insert "\n=== Diff ===\n\n")
468 (github-notifications--call-process-async
469 (lambda (output)
470 (let ((inhibit-read-only t))
471 (save-excursion
472 (goto-char (point-max))
473 (insert output)
474 (let ((diff-start (save-excursion
475 (goto-char (point-min))
476 (search-forward "\n=== Diff ===\n\n" nil t))))
477 (when diff-start
478 (diff-mode-setup))))))
479 nil
480 "pr-diff"
481 "pr" "diff" pr-number "--repo" repo))))
482
483(defun github-notifications--get-issue-number (url)
484 "Extract issue number from URL."
485 (when (string-match "/issues/\\([0-9]+\\)" url)
486 (match-string 1 url)))
487
488(defun github-notifications--format-checks (checks)
489 "Format checks data for display."
490 (mapconcat
491 (lambda (check)
492 (let-alist check
493 (format "%s: %s"
494 .name
495 (propertize .status 'face
496 (pcase .status
497 ("success" '(:foreground "green"))
498 ("failure" '(:foreground "red"))
499 (_ '(:foreground "yellow")))))))
500 checks "\n"))
501
502;;;###autoload
503(defun github-notifications ()
504 "Display GitHub notifications in a buffer."
505 (interactive)
506 (github-notifications--ensure-gh)
507 (let ((buffer (get-buffer-create github-notifications-buffer-name)))
508 (with-current-buffer buffer
509 (github-notifications-fetch))
510 (switch-to-buffer buffer)))
511
512(provide 'github-notifications-gh)
513;;; github-notifications-gh.el ends here