;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-

(defun powellc/reload-config ()
  (interactive)
  (load-file (expand-file-name "config.el" doom-private-dir)))

(setq user-full-name "Colin Powell"
      user-mail-address "colin@unbl.ink")

(nyan-mode)

(after! envrc
  (envrc-global-mode))

;; Force user ssh-agent socket for all Emacs subprocesses.
(defun my/apply-ssh-agent-env ()
  "Set SSH_AUTH_SOCK in Emacs using ~/.local/bin/ssh-agent-env."
  (let* ((cmd (expand-file-name "~/.local/bin/ssh-agent-env"))
         (out (and (file-executable-p cmd)
                   (string-trim (shell-command-to-string cmd)))))
    ;; out looks like: export SSH_AUTH_SOCK='...'
    (when (and out (string-match "SSH_AUTH_SOCK='\\([^']+\\)'" out))
      (setenv "SSH_AUTH_SOCK" (match-string 1 out)))))

(my/apply-ssh-agent-env)

;; load pinentry
(when (require 'pinentry nil t)
  (pinentry-start))
(require 'server)
(unless (server-running-p)
  (server-start))

(setq doom-theme 'doom-xcode
      doom-font (font-spec :family "Iosevka" :size 14 :weight 'regular)
      doom-big-font (font-spec :family "Iosevka" :size 18 :weight 'regular)
      doom-variable-pitch-font (font-spec :family "Overpass" :size 12))

(setq display-line-numbers-type t)

;; change `org-directory'. It must be set before org loads!
(setq org-directory "~/var/org/")

(load! "+agenda-fix")
(defun vulpea-agenda-files-update (&rest _)
  (setq org-agenda-files vulpea-project-files))

(setq org-roam-directory "~/var/org/"
      org-roam-dailies-directory "dailies")

(setq org-roam-dailies-capture-templates
  '(("d" "default" entry
     "* Meetings :work:hungryroot:meeting:\n* Tasks :work:hungryroot:\n** %?"
     :target (file+head "%<%Y-%m-%d>.org"
                        "#+title: %<%Y-%m-%d>\n")
     :empty-lines 1)))

(advice-add 'org-agenda :before #'vulpea-agenda-files-update)
(advice-add 'org-todo-list :before #'vulpea-agenda-files-update)

;; Open HR term popup (vterm with hrdjsh)
(defun powellc/open-hr-term ()
  "Open a vterm popup and run hrdjsh."
  (interactive)
  (let ((buf (vterm t)))
    (pop-to-buffer buf)
    (vterm-send-string "hrdjsh\n")))

;; Open Cursor Agent term popup (vterm with agent)
(defun powellc/open-cursor-agent ()
  "Open a vterm popup and run agent."
  (interactive)
  (let ((buf (vterm t)))
    (pop-to-buffer buf)
    (vterm-send-string "agent\n")))

;; Open Opencode Agent term popup (vterm with opencode)
(defun powellc/open-opencode-agent ()
  "Open a vterm popup and run agent."
  (interactive)
  (let ((buf (vterm t)))
    (pop-to-buffer buf)
    (vterm-send-string "opencode\n")))

(load! "+django-tests")
(map! :after python
      :map python-mode-map
      :localleader
      (:prefix ("t" . "tests")
       :desc "django test at point" "t" #'django-run-test-at-point
       :desc "django tests for file" "f" #'django-run-tests-for-current-file
       :desc "django all tests" "a" (cmd! (django-run-tests "" nil))))

(setq +format-on-save-disabled-modes (add-to-list '+format-on-save-disabled-modes 'typescript-mode))

(map! ;; Easier window movement
 :n "C-h" 'evil-window-left
 :n "C-j" 'evil-window-down
 :n "C-k" 'evil-window-up
 :n "C-l" 'evil-window-right

 (:map evil-treemacs-state-map
       "C-h" 'evil-window-left
       "C-l" 'evil-window-right)

 :leader
 (:prefix "f"
  :desc "Find file in dotfiles"   "t" #'+hlissner/find-in-dotfiles
  :desc "Browse dotfiles"         "T" #'+hlissner/browse-dotfiles)
 (:prefix "b"
  :desc "Black format buffer"     "f" #'blacken-buffer
  :desc "isort buffer"            "I" #'py-isort-buffer
  :desc "Links in buffer"         "l" #'ace-link-org)
 (:prefix "o"
  :desc "Open curosr shell"     "C" #'powellc/open-cursor-agent
  :desc "Open curosr shell"     "c" #'powellc/open-opencode-agent
  :desc "Open Hungryroot shell"     "S" #'powellc/open-hr-term)
 (:prefix "g"
  :desc "PR comments for branch"   "p" #'powellc/gh-pr-comments-for-branch))

(defun unfill-paragraph ()
  "Takes a multi-line paragraph and makes it into a single line of text."
  (interactive)
  (let ((fill-column (point-max)))
    (fill-paragraph nil)))

(define-key global-map "\M-z" 'unfill-paragraph)

(defun file-notify-rm-all-watches ()
  "Remove all existing file notification watches from Emacs."
  (interactive)
  (maphash
   (lambda (key _value)
     (file-notify-rm-watch key))
   file-notify-descriptors))

(setq frame-title-format
      '(""
        (:eval
         (if (s-contains-p org-roam-directory (or buffer-file-name ""))
             (replace-regexp-in-string
              ".*/[0-9]*-?" "☰ "
              (subst-char-in-string ?_ ?  buffer-file-name))
           "%b"))
        (:eval
         (let ((project-name (projectile-project-name)))
           (unless (string= "-" project-name)
             (format (if (buffer-modified-p)  " ◉ %s" "  ●  %s") project-name))))))

(setq mm-text-html-renderer 'w3m)
(setq w3m-fill-column 88)

(setq lsp-lens-enable 1
      lsp-ui-sideline-enable 1
      lsp-enable-links 1
      lsp-headerline-breadcrumb-enable 1
      lsp-modeline-code-actions-enable 1
      lsp-modeline-diagnostics-enable 1
      lsp-completion-show-detail 1
      lsp-file-watch-threshold nil)


;; check for hosts folder and find any init-HOSTNAME.el files in there and load them
(defvar host (substring (shell-command-to-string "hostname") 0 -1))
(defvar host-dir "~/.config/doom/hosts/")
(add-load-path! host-dir)

;; Setup nov.el mode for epubs and change font
(add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode))
(defun my-nov-font-setup ()
  (face-remap-add-relative 'variable-pitch :family "Overpass"
                           :height 1.0))
(add-hook 'nov-mode-hook 'my-nov-font-setup)

;;(let ((init-host-feature (intern (concat "init-" host ".el"))))
;;  (load-file init-host-feature))

(defvar host-init (concat "~/.config/doom/hosts/init-" host ".el"))
(if (file-exists-p host-init)
    (load-file host-init))
(load-file "~/.config/doom/+agenda-fix.el")

;; Enable org-modern mode per buffer
                                        ;(add-hook 'org-mode-hook #'org-modern-mode)
                                        ;(add-hook 'org-agenda-finalize-hook #'org-modern-agenda)

;; Or globally
(with-eval-after-load 'org (global-org-modern-mode))
(require 'cl-lib)


(defun eshell-load-bash-aliases ()
  "Read Bash aliases and add them to the list of eshell aliases."
  ;; Bash needs to be run - temporarily - interactively
  ;; in order to get the list of aliases.
  (with-temp-buffer
    (call-process "bash" nil '(t nil) nil "-ci" "alias")
    (goto-char (point-min))
    (cl-letf (((symbol-function 'eshell-write-aliases-list) #'ignore))
      (while (re-search-forward "alias \\(.+\\)='\\(.+\\)'$" nil t)
        (eshell/alias (match-string 1) (match-string 2))))
    (eshell-write-aliases-list)))

;; We only want Bash aliases to be loaded when Eshell loads its own aliases,
;; rather than every time `eshell-mode' is enabled.
(add-hook 'eshell-alias-load-hook 'eshell-load-bash-aliases)

(defun eshell-run-direnv-allow()
  (direnv-allow))

(add-hook 'eshell-directory-change-hook 'eshell-run-direnv-allow)

(defun org-raw-timestamp-to-iso (raw-ts)
  "Convert Org RAW-TS like `<2025-06-12 Thu 14:00>` to `YYYY-MM-DDThh:mm:ss`."
  (when raw-ts
    (let* ((ts (org-parse-time-string raw-ts))
           (year (nth 5 ts)) (mon (nth 4 ts)) (day (nth 3 ts))
           (hour (nth 2 ts) 0) (min (nth 1 ts) 0))
      (format "%04d-%02d-%02dT%02d:%02d:00" year mon day hour min))))

(defun org-extract-labeled-timestamps ()
  "Return an alist of labeled ISO-formatted timestamps in the current Org subtree."
  (save-restriction
    (org-narrow-to-subtree)
    (let ((parsed (org-element-parse-buffer))
          (labeled-ts '()))
      (org-element-map parsed '(timestamp)
        (lambda (ts)
          (let* ((type (org-element-property :type ts))
                 (raw (org-element-property :raw-value ts))
                 (time (org-parse-time-string raw t))
                 (date (format "%04d-%02d-%02d"
                               (nth 5 time) (nth 4 time) (nth 3 time)))
                 (hour (nth 2 time))
                 (min (nth 1 time))
                 (with-time (and hour min (format "%sT%02d:%02d" date hour min)))
                 (label (cond
                         ((eq type 'active) "timestamp")
                         ((eq type 'inactive) "inactive-timestamp")
                         (t "timestamp"))))
            (push (cons label (or with-time date)) labeled-ts))))
      ;; Add planning info from heading (DEADLINE, SCHEDULED, CLOSED)
      (dolist (key '("DEADLINE" "SCHEDULED" "CLOSED"))
        (let ((raw (org-entry-get nil key t)))
          (when raw
            (let* ((ts (org-parse-time-string raw t))
                   (date (format "%04d-%02d-%02d" (nth 5 ts) (nth 4 ts) (nth 3 ts)))
                   (hour (nth 2 ts))
                   (min (nth 1 ts))
                   (with-time (and hour min (format "%sT%02d:%02d" date hour min))))
              (push (cons (downcase key) (or with-time date)) labeled-ts)))))
      (delete-dups labeled-ts))))

(defun org-get-body ()
  "Return the body text under the current Org heading as a string."
  (save-excursion
    (org-back-to-heading t)
    (let ((start (progn (forward-line) (point)))
          (end (progn (org-end-of-subtree t t) (point))))
      (buffer-substring-no-properties start end))))

(defun org-strip-timestamps-from-text (text)
  "Remove Org timestamps and planning lines from TEXT."
  (let* ((timestamp-re (rx (or (seq "<" (+ (not (any ">"))) ">")
                               (seq "[" (+ (not (any "]"))) "]"))))
         (planning-line-re (rx line-start (zero-or-more space)
                               (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " "))
         ;; Step 1: remove full planning lines
         (without-planning-lines
          (replace-regexp-in-string
           (concat planning-line-re ".*\n?") "" text))
         ;; Step 2: remove inline timestamps
         (without-inline
          (replace-regexp-in-string timestamp-re "" without-planning-lines)))
    (string-trim without-inline)))

(defun org-extract-drawers ()
  "Extract all drawers (like LOGBOOK, PROPERTIES, etc.) from current org entry.
Returns an alist of (DRAWER-NAME . CONTENT) pairs.
- PROPERTIES content is parsed into (KEY . VALUE)
- Other drawers are returned as lists of lines (strings)"
  (save-excursion
    (org-back-to-heading t)
    (let ((end (save-excursion (org-end-of-subtree t t)))
          (drawers '()))
      (while (re-search-forward "^\\s-*:\\([A-Z]+\\):\\s-*$" end t)
        (let* ((name (match-string 1))
               (start (match-end 0))
               (drawer-end (when (re-search-forward "^\\s-*:END:\\s-*$" end t)
                             (match-beginning 0))))
          (when drawer-end
            (let ((content (buffer-substring-no-properties start drawer-end)))
              (setq drawers
                    (cons
                     (cons name
                           (if (string= name "PROPERTIES")
                               ;; parse :KEY: VALUE
                               (org-parse-properties content)
                             ;; just return line list
                             (split-string content "\n" t "[ \t]+")))
                     drawers))))))
      (reverse drawers))))

(defun org-parse-properties (content)
  "Parse PROPERTIES drawer content into an alist."
  (let ((lines (split-string content "\n" t))
        (props '()))
    (dolist (line lines)
      (when (string-match "^\\s-*:\\([^:]+\\):\\s-*\\(.*\\)$" line)
        (push (cons (match-string 1 line) (match-string 2 line)) props)))
    (reverse props)))

(defun org-clean-body-text (text)
  "Remove planning lines, timestamps, and drawers from TEXT."
  (let* ((timestamp-re
          (rx (or (seq "<" (+ (not (any ">"))) ">")
                  (seq "[" (+ (not (any "]"))) "]"))))
         (planning-re
          (rx line-start (zero-or-more space)
              (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
         (text (replace-regexp-in-string planning-re "" text))
         (text (replace-regexp-in-string timestamp-re "" text))
         (text (org-strip-all-drawers text)))
    (string-trim text)))

(defun org-strip-timestamps-drawers-notes-from-text (text)
  "Strip timestamps, planning lines, drawers, and note blocks from Org TEXT."
  (let* ((timestamp-re
          (rx (or (seq "<" (+ (not (any ">"))) ">")
                  (seq "[" (+ (not (any "]"))) "]"))))
         (planning-re
          (rx line-start (zero-or-more space)
              (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
         (drawer-re
          "^\\s-*:[A-Z]+:\\(?:.\\|\n\\)*?:END:\n?")
         (note-block-re
          (rx-to-string
           `(and bol (* space) "- Note taken on "
                 (or "[" "<") (+ (not (any "]>"))) (or "]" ">")
                 (*? anything)
                 (or "\n\n" eos))
           t)))

    ;; Strip drawers first
    (setq text (replace-regexp-in-string drawer-re "" text))

    ;; Strip entire note blocks (greedy match up to next blank line or end)
    (setq text (replace-regexp-in-string note-block-re "" text))

    ;; Strip planning lines and timestamps
    (setq text (replace-regexp-in-string planning-re "" text))
    (setq text (replace-regexp-in-string timestamp-re "" text))

    ;; Trim and return
    (string-trim text)))

(defun org-get-body-stripped ()
  "Get cleaned Org entry body without timestamps, planning lines, drawers, or notes."
  (org-strip-timestamps-drawers-notes-from-text (org-get-body)))


(defun org-extract-notes ()
  "Extract notes from Org entry, each as an alist with `timestamp` and `content`."
  (save-excursion
    (org-back-to-heading t)
    (let ((start (progn (forward-line) (point)))
          (end (progn (org-end-of-subtree t t) (point)))
          result)  ;; ✅ initialize result list
      (save-restriction
        (narrow-to-region start end)
        (goto-char (point-min))
        (while (re-search-forward "^\\s-*[-+] Note taken on \\[\\([^]]+\\)\\]\\s-*\\(?:\\\\\\\\\\)?\\s-*$" nil t)
          (let* ((raw-ts (match-string 1))
                 (timestamp (let* ((ts (org-parse-time-string raw-ts t)))
                              (format "%04d-%02d-%02dT%02d:%02d"
                                      (nth 5 ts) (nth 4 ts) (nth 3 ts)
                                      (nth 2 ts) (nth 1 ts))))
                 (note-start (progn
                               (forward-line)
                               ;; allow one optional blank line
                               (when (looking-at-p "^\\s-*$") (forward-line))
                               (point)))
                 (note-end (or (save-excursion
                                 (re-search-forward "^\\s-*[-+] Note taken on \\[" nil t))
                               (point-max)))
                 (content (string-trim
                           (buffer-substring-no-properties note-start (1- note-end)))))
            (push `(("timestamp" . ,timestamp)
                    ("content" . ,content))
                  result))))
      (nreverse result))))

(require 'subr-x) ;; for string-trim and string functions, usually loaded by default

(defun my-org-generate-uuid ()
  "Generate a random UUID string."
  (let ((uuid (md5 (format "%s%s%s%s%s"
                           (user-uid)
                           (emacs-pid)
                           (float-time)
                           (random)
                           (emacs-pid)))))
    (concat (substring uuid 0 8) "-"
            (substring uuid 8 12) "-"
            (substring uuid 12 16) "-"
            (substring uuid 16 20) "-"
            (substring uuid 20 32))))



(defun my-org-get-or-create-id ()
  "Get the ID property of the current Org heading, or create and set one if missing.
Returns the ID string."
  (let ((id (org-entry-get nil "ID")))
    (unless id
      (setq id (my-org-generate-uuid))
      (org-entry-put nil "ID" id)
      (save-buffer)) ;; optional: save file after inserting ID
    id))

(defun org-clock-on-state-change ()
  "Clock in when state is STRT, clock out otherwise."
  (when (and (derived-mode-p 'org-mode)
             (not (org-before-first-heading-p)))
    (pcase org-state
      ("STRT"
       (unless org-clock-marker
         (org-clock-in)))
      ((or "DONE" "CANC" "WAIT" "HOLD" "TODO")
       (when org-clock-marker
         (org-clock-out))))))

(defun send-org-todo-to-endpoint-on-state-change ()
  "Send the current Org-mode TODO item to an HTTP endpoint."
  (interactive)
  (when (org-at-heading-p)
    (let ((state (org-get-todo-state)))
      (when (member state '("STRT" "DONE"))
        (let* ((heading (org-get-heading t t t t))
               (current-time (format-time-string "%Y-%m-%dT%H:%M:%SZ" (current-time) t)) ;; UTC ISO8601
               (tags (org-get-tags))
               (timestamps (org-extract-labeled-timestamps))
               (drawers (org-extract-drawers))
               (properties (cdr (assoc "PROPERTIES" drawers)))
               (todo-id (my-org-get-or-create-id))
               (body (org-get-body-stripped))
               (notes (org-extract-notes))
               (properties (org-entry-properties))
               (endpoint "https://life.lab.unbl.ink/webhook/emacs/")
               (data `(("description" . ,heading)
                       ("labels" . ,tags)
                       ("state" . ,state)
                       ("timestamps" . ,timestamps)
                       ("notes" . ,notes)
                       ("drawers" . ,drawers)
                       ("emacs_id" . ,todo-id)
                       ("updated_at" . ,current-time)
                       ("source" . "orgmode")
                       ("properties" . ,properties)
                       ("body" . ,body))))
          (request
            endpoint
            :type "POST"
            :data (json-encode data)
            :headers '(("Authorization" . "Token 58e898c0e88bd6333b1a9e8de82e81f36c4b64e4")
                       ("Content-Type" . "application/json"))
            :parser 'json-read
            :success (cl-function
                      (lambda (&key data &allow-other-keys)
                        (message "Sent TODO: %s" data)))
            :error (cl-function
                    (lambda (&rest args &key error-thrown &allow-other-keys)
                      (message "Error sending TODO: %S with %S" error-thrown args))))))
      (org-clock-on-state-change)
      )))

(add-hook 'org-after-todo-state-change-hook #'send-org-todo-to-endpoint-on-state-change)

(defun life-scrobble-url ()
  "Open https://life.lab.unbl.ink/ with scrobble_url set to a URL.
- If in an `eww` buffer, use its current URL.
- Otherwise, use the clipboard/kill ring.
Always open the result in `eww`."
  (interactive)
  (let* ((url (cond
               ;; In eww, grab current page URL
               ((derived-mode-p 'eww-mode)
                (plist-get eww-data :url))
               ;; Else clipboard
               (t (current-kill 0 t))))
         (encoded (url-hexify-string url))
         (scrobble-url (concat "https://life.lab.unbl.ink/?scrobble_url=" encoded)))
    (eww scrobble-url)))

;; Bind globally to C-c l
(global-set-key (kbd "C-c l") #'life-scrobble-url)

                                        ; (after! magit
                                        ;   (defvar my/ssh-key-injector-script (expand-file-name "~/.local/bin/load_keys"))
                                        ; 
                                        ;   (defun my/ssh-add-status ()
                                        ;     (call-process "ssh-add" nil nil nil "-l"))
                                        ; 
                                        ;   (defun my/run-ssh-key-injector ()
                                        ;     (my/apply-ssh-agent-env)
                                        ;     (let* ((buf (get-buffer-create "*ssh-key-injector*"))
                                        ;            (process-connection-type t)
                                        ;            (process-environment (copy-sequence process-environment)))
                                        ;       (unless (file-executable-p my/ssh-key-injector-script)
                                        ;         (user-error "SSH injector script not executable: %s" my/ssh-key-injector-script))
                                        ;       (with-current-buffer buf
                                        ;         (erase-buffer)
                                        ;         (insert (format "Emacs SSH_AUTH_SOCK=%s\n\n" (or (getenv "SSH_AUTH_SOCK") "<unset>"))))
                                        ;       (let ((proc (make-process
                                        ;                    :name "ssh-key-injector"
                                        ;                    :buffer buf
                                        ;                    :stderr buf
                                        ;                    :command (list my/ssh-key-injector-script)
                                        ;                    :noquery t)))
                                        ;         (while (process-live-p proc)
                                        ;           (accept-process-output proc 0.05))
                                        ;         (unless (eq (process-exit-status proc) 0)
                                        ;           (display-buffer buf)
                                        ;           (user-error "SSH key injection failed. See *ssh-key-injector*.")))))
                                        ; 
                                        ;   (defun my/ensure-ssh-keys-loaded (&rest _ignore)
                                        ;     (pcase (my/ssh-add-status)
                                        ;       (0 nil)
                                        ;       (1 (my/run-ssh-key-injector))
                                        ;       (2 (user-error "No reachable ssh-agent (SSH_AUTH_SOCK=%s)"
                                        ;                      (or (getenv "SSH_AUTH_SOCK") "<unset>")))
                                        ;       (_ (my/run-ssh-key-injector))))
                                        ; 
                                        ;   (dolist (fn '(magit-status magit-fetch magit-push magit-pull))
                                        ;     (advice-remove fn #'my/ensure-ssh-keys-loaded)
                                        ;     (advice-add fn :before #'my/ensure-ssh-keys-loaded)))

(defun my/disable-apheleia-in-certain-projects ()
  (when-let ((root (and (fboundp 'project-root)
                        (project-root (project-current)))))
    (when (member (file-name-nondirectory
                   (directory-file-name root))
                  '("hungryroot"))
      (apheleia-mode -1))))

(add-hook 'find-file-hook #'my/disable-apheleia-in-certain-projects)

(require 'acp)
(require 'agent-shell)
;; --- pass integration ---
(after! auth-source-pass
  ;; Ensure pass is used as an auth-source backend
  (auth-source-pass-enable))

(defun +pass-line1 (entry)
  "Return first line from `pass show ENTRY` (trimmed)."
  (string-trim
   (shell-command-to-string (format "pass show %s 2>/dev/null | head -n 1" entry))))

(after! agent-shell
  ;; OpenAI (ChatGPT API)
  (setq agent-shell-openai-authentication
        (agent-shell-openai-make-authentication
         :api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/chatgpt"))))

  ;; Anthropic (Claude)
  (setq agent-shell-anthropic-authentication
        (agent-shell-anthropic-make-authentication
         :api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/anthropic"))))

  ;; Google (Gemini)
  (setq agent-shell-google-authentication
        (agent-shell-google-make-authentication
         :api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/gemini")))))

(defun powellc/gh-pr-comments-for-branch ()
  "Select a branch via projectile and pull its PR comments into a new buffer."
  (interactive)
  (require 'projectile)
  (let* ((project-root (projectile-project-root))
         (default-directory (or project-root default-directory)))
    (unless project-root
      (user-error "Not in a projectile project"))
    (let* ((branch-output (shell-command-to-string "git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null"))
           (branches (seq-filter (lambda (s) (and (not (string-empty-p s)) (not (string-prefix-p " " s))))
                                 (split-string branch-output "\n" t))))
      (unless branches
        (user-error "No branches found"))
      (let* ((branch (projectile-completing-read "Select branch:" branches))
             (pr-json (shell-command-to-string
                       (format "gh pr list --state all --json id,number,title,headRefName 2>/dev/null")))
             (pr-list (ignore-errors (mapcar #'json-parse-object (json-read-from-string pr-json))))
             (matching-pr (seq-find (lambda (pr) (string= (alist-get 'headRefName pr) branch)) pr-list)))
        (if (or (null matching-pr) (not matching-pr))
            (user-error "No PR found for branch: %s" branch)
          (let* ((pr matching-pr)
                 (pr-id (alist-get 'id pr))
                 (pr-number (number-to-string (alist-get 'number pr)))
                 (pr-title (alist-get 'title pr))
                 (comments-output (shell-command-to-string
                                   (format "gh pr view %s --comments --json body,author,createdAt,path,line 2>/dev/null"
                                           (shell-quote-argument pr-id))))
                 (comments (ignore-errors (mapcar #'json-parse-object (json-read-from-string comments-output)))))
            (if (or (null comments) (seq-empty-p comments))
                (user-error "No comments found for PR #%s" pr-number)
              (let ((buf (get-buffer-create (format "*PR #%s Comments*" pr-number))))
                (with-current-buffer buf
                  (erase-buffer)
                  (insert (format "# PR #%s: %s\n" pr-number pr-title))
                  (insert (format "Branch: %s\n\n" branch))
                  (insert "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"))
                (dolist (comment comments)
                  (let ((author (alist-get 'author comment))
                        (body (alist-get 'body comment))
                        (created-at (alist-get 'createdAt comment))
                        (path (alist-get 'path comment))
                        (line (alist-get 'line comment)))
                    (with-current-buffer buf
                      (insert (format "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"))
                      (insert (format "Author: %s\n" (if (stringp author) author (alist-get 'login author))))
                      (insert (format "Date: %s\n" created-at))
                      (when path
                        (insert (format "File: %s" path))
                        (when line (insert (format ":%s" line)))
                        (insert "\n"))
                      (insert "\n")
                      (insert (or body ""))
                      (insert "\n\n")))
                  (pop-to-buffer buf))))))))))
