Last active
May 29, 2026 22:14
-
-
Save bimawa/210d69d969c988fbbe6cfd6f70fe8eb8 to your computer and use it in GitHub Desktop.
code-review addon: toggle file viewed state, sync with GitHub (gh CLI)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;;; 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