Skip to content

Instantly share code, notes, and snippets.

@bimawa
Last active May 29, 2026 22:14
Show Gist options
  • Select an option

  • Save bimawa/210d69d969c988fbbe6cfd6f70fe8eb8 to your computer and use it in GitHub Desktop.

Select an option

Save bimawa/210d69d969c988fbbe6cfd6f70fe8eb8 to your computer and use it in GitHub Desktop.
code-review addon: toggle file viewed state, sync with GitHub (gh CLI)
;;; cr-viewed.el — mark files as viewed in code-review, sync with GitHub
;;; Load: (after! code-review
;;; (load "~/Developing/Repos/cr-viewed.el")
;;; (transient-append-suffix 'code-review-transient-api "s d"
;;; '("s v" "Toggle Viewed" cr-toggle-file-viewed)))
;;; Keys: v = toggle viewed on current file
;;; r → s v = same via transient menu
;;; Requires: gh CLI (gh auth login)
(require 'json)
(defvar cr--viewed-files nil
"Alist of (path . viewed-p) for the current PR.")
(defvar cr--viewed-overlays '()
"Overlays for viewed markers.")
;;; Helpers
(defun cr--total-files ()
"Get total number of files in current PR."
(when-let* ((pr (ignore-errors (code-review-db-get-pullreq)))
(raw (oref pr raw-infos))
(files (a-get-in raw '(files nodes))))
(length files)))
(defun cr--viewed-count ()
"Count files marked as viewed."
(length (-filter 'cdr cr--viewed-files)))
(defun cr--update-header ()
"Update header-line-format to show viewed/total counter."
(with-current-buffer (get-buffer "*Code Review*")
(let* ((pr (ignore-errors (code-review-db-get-pullreq)))
(total (cr--total-files))
(viewed (cr--viewed-count)))
(when (and pr total)
(setq header-line-format
(format "%s [%s/%s viewed]"
(propertize (format "#%s: %s" (oref pr number) (oref pr title))
'face 'magit-section-heading)
(propertize (format "%d" viewed) 'face '(:foreground "#4ae04a" :weight bold))
(propertize (format "%d" total) 'face '(:foreground "dim gray"))))))))
(defun cr--file-path-at-point ()
"Get file path from the magit file section at point."
(when-let* ((section (magit-current-section))
(file-section
(if (eq (oref section type) 'file) section
(cl-loop for p = (oref section parent) then (oref p parent)
while p
when (eq (oref p type) 'file) return p)))
(value (oref file-section value)))
(if (stringp value) value (car-safe value))))
(defun cr--pr-global-id ()
"Get GitHub global node ID from current PR's raw GraphQL data."
(when-let* ((pr (ignore-errors (code-review-db-get-pullreq)))
(raw (oref pr raw-infos)))
(a-get raw 'id)))
(defun cr--owner-repo-number ()
"Return (owner repo number) for current PR."
(when-let (pr (ignore-errors (code-review-db-get-pullreq)))
(list (oref pr owner) (oref pr repo) (oref pr number))))
;;; gh CLI — JSON body via json-encode + --input to avoid quoting issues
(defun cr--gh (args)
"Run gh with ARGS (list of strings). Return (exit-code . output)."
(let ((cmd (mapconcat
(lambda (s)
(concat "'" (replace-regexp-in-string "'" "'\\\\''" s nil t) "'"))
(cons "gh" args) " ")))
(with-temp-buffer
(let ((exit (call-process-shell-command (concat cmd " 2>&1") nil
(list (current-buffer) nil) nil)))
(cons exit (buffer-string))))))
(defun cr--gh-graphql (query &optional jqfilter)
"Run gh api graphql with QUERY string. Returns (exit . output)."
(let* ((body (json-encode `(("query" . ,query))))
(bfile (make-temp-file "cr-gh-body-")))
(unwind-protect
(progn
(with-temp-file bfile (insert body))
(cr--gh (append (list "api" "graphql" "--input" bfile)
(if jqfilter (list "--jq" jqfilter)))))
(ignore-errors (delete-file bfile)))))
;;; Overlays
(defun cr--add-overlay (path)
"Add checkmark overlay for PATH in code-review buffer."
(with-current-buffer (get-buffer "*Code Review*")
(save-excursion
(goto-char (point-min))
(while (re-search-forward
(concat "^[ \t]*\\(modified\\|added\\|deleted\\|renamed\\|new file\\|copied\\)"
"[ \t]+" (regexp-quote path) "$")
nil t)
(let ((start (match-beginning 0)))
(unless (cl-find-if (lambda (ov) (string= (overlay-get ov 'cr-path) path))
(overlays-in start (1+ start)))
(let ((ov (make-overlay start (1+ start))))
(overlay-put ov 'cr-path path)
(overlay-put ov 'evaporate t)
(overlay-put ov 'before-string
(propertize " ✓ " 'face '(:foreground "#4ae04a" :weight bold))))))))))
(defun cr--remove-overlay (path)
"Remove checkmark overlay for PATH."
(with-current-buffer (get-buffer "*Code Review*")
(dolist (ov (overlays-in (point-min) (point-max)))
(when (string= (overlay-get ov 'cr-path) path)
(delete-overlay ov)))))
;;; Toggle viewed
(defun cr-toggle-file-viewed ()
"Toggle viewed state for file at point, sync with GitHub."
(interactive)
(let* ((path (cr--file-path-at-point))
(pr-id (cr--pr-global-id))
(pair (assoc path cr--viewed-files))
(viewed (and pair (cdr pair))))
(unless path (user-error "Not on a file section"))
(unless pr-id (user-error "No GitHub node ID"))
(if viewed
(let ((r (cr--gh-graphql
(format "mutation { unmarkFileAsViewed(input: {pullRequestId: \"%s\", path: \"%s\"}) { pullRequest { id } } }"
pr-id path))))
(cr--remove-overlay path)
(setcdr pair nil)
(cr--update-header)
(if (= 0 (car r))
(message "Unviewed %s" path)
(message "gh error (exit %d): %s" (car r) (cdr r))))
(let ((r (cr--gh-graphql
(format "mutation { markFileAsViewed(input: {pullRequestId: \"%s\", path: \"%s\"}) { pullRequest { id } } }"
pr-id path))))
(cr--add-overlay path)
(if pair (setcdr pair t) (push (cons path t) cr--viewed-files))
(cr--update-header)
(if (= 0 (car r))
(message "Viewed %s" path)
(message "gh error (exit %d): %s" (car r) (cdr r)))))))
;;; Fetch from GitHub
(defun cr-fetch-viewed-files ()
"Fetch viewed status from GitHub and show overlays.
Call from code-review-mode-hook with a short delay."
(interactive)
(condition-case err
(when-let* ((pr (ignore-errors (code-review-db-get-pullreq)))
(owner-repo (cr--owner-repo-number))
(owner (nth 0 owner-repo))
(repo (nth 1 owner-repo))
(number (format "%s" (nth 2 owner-repo))))
(message "cr-viewed: checking %s/%s#%s..." owner repo number)
(let* ((jqfilter ".data.repository.pullRequest.files.nodes[] | select(.viewerViewedState == \"VIEWED\") | .path")
(r (cr--gh-graphql
(format "query { repository(owner: \"%s\", name: \"%s\") { pullRequest(number: %s) { files(first: 100) { nodes { path viewerViewedState } } } } }"
owner repo number)
jqfilter)))
(if (= 0 (car r))
(let ((count 0))
(setq cr--viewed-files nil)
(dolist (line (split-string (cdr r) "\n" t))
(push (cons line t) cr--viewed-files)
(cr--add-overlay line)
(setq count (1+ count)))
(cr--update-header)
(message "cr-viewed: %d viewed file(s) loaded" count))
(message "cr-viewed: gh error (exit %d): %s" (car r) (cdr r)))))
(error (message "cr-viewed: error: %S" err))))
;;; Install — delay to let PR fully load before fetching
(add-hook 'code-review-mode-hook
(lambda ()
(run-at-time 0.5 nil #'cr--update-header)
(run-at-time 1 nil #'cr-fetch-viewed-files)))
(with-eval-after-load 'code-review
(define-key code-review-mode-map (kbd "v") 'cr-toggle-file-viewed))
(provide 'cr-viewed)
;;; cr-viewed.el ends here
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment