branch: externals/forgejo
commit 21cfae9b7ae5ce85f0554850e9e9b2a9617f7a38
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
view: Follow issue comment deeplinks
---
lisp/forgejo-issue.el | 50 ++++++-----
lisp/forgejo-pull.el | 50 ++++++-----
lisp/forgejo-view.el | 153 +++++++++++++++++++++------------
tests/forgejo-test-issue.el | 94 ++++++++++++++++++++
tests/forgejo-test-pull.el | 95 +++++++++++++++++++++
tests/forgejo-test-view.el | 204 ++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 549 insertions(+), 97 deletions(-)
diff --git a/lisp/forgejo-issue.el b/lisp/forgejo-issue.el
index c630c88434..80b5647644 100644
--- a/lisp/forgejo-issue.el
+++ b/lisp/forgejo-issue.el
@@ -256,8 +256,9 @@ Empty input clears all filters."
(defun forgejo-issue--render-detail (buf-name host-url owner repo issue-alist
timeline-alists &optional
comment-id)
"Render issue detail into BUF-NAME from alist data.
-HOST-URL is the instance URL. When COMMENT-ID is non-nil, jump to
-that comment after rendering."
+HOST-URL is the instance URL. OWNER and REPO identify the repository.
+ISSUE-ALIST is the issue data and TIMELINE-ALISTS is its timeline. When
+COMMENT-ID is non-nil, jump to that comment after rendering."
(forgejo-view--render-detail buf-name host-url owner repo issue-alist
timeline-alists
#'forgejo-issue-view-mode
@@ -266,9 +267,10 @@ that comment after rendering."
comment-id))
(defun forgejo-issue--sync-detail (host owner repo number buf-name
- &optional restore-line)
- "Sync issue NUMBER from API in background, re-render BUF-NAME if changed.
-When RESTORE-LINE is non-nil, go to that line after re-rendering."
+ &optional restore-line comment-id)
+ "Sync issue NUMBER from API for HOST OWNER/REPO into BUF-NAME.
+When RESTORE-LINE is non-nil, go to that line after re-rendering.
+When COMMENT-ID is non-nil, jump to that comment after re-rendering."
(let ((host-url (forgejo--host-url-for-hostname host))
(endpoint (format "repos/%s/%s/issues/%d" owner repo number))
(tl-endpoint (format "repos/%s/%s/issues/%d/timeline"
@@ -287,7 +289,7 @@ When RESTORE-LINE is non-nil, go to that line after
re-rendering."
(format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
(forgejo-view--re-render
buf-name host-url host owner repo number
- #'forgejo-issue--render-detail restore-line)
+ #'forgejo-issue--render-detail restore-line comment-id)
(forgejo-view--sync-reactions
host-url host owner repo number buf-name timeline)))))))
@@ -312,22 +314,26 @@ When COMMENT-ID is non-nil, jump to that comment after
rendering."
(buf-name (format "*forgejo-issue: %s/%s#%d*" owner repo number))
(issue-alist (forgejo-db-get-issue host owner repo number))
(tl-rows (forgejo-db-get-timeline host owner repo number))
- (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
- (if issue-alist
- (switch-to-buffer
- (forgejo-issue--render-detail buf-name host-url owner repo
- issue-alist tl-alists comment-id))
- (with-current-buffer (get-buffer-create buf-name)
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert "Loading..."))
- (forgejo-issue-view-mode)
- (setq forgejo-repo--host host-url
- forgejo-repo--owner owner
- forgejo-repo--name repo
- forgejo-view--target-comment-id comment-id)
- (switch-to-buffer (current-buffer))))
- (forgejo-issue--sync-detail host owner repo number buf-name)))
+ (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows))
+ (buf (if issue-alist
+ (forgejo-issue--render-detail buf-name host-url owner repo
+ issue-alist tl-alists
comment-id)
+ (with-current-buffer (get-buffer-create buf-name)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "Loading..."))
+ (forgejo-issue-view-mode)
+ (setq forgejo-repo--host host-url
+ forgejo-repo--owner owner
+ forgejo-repo--name repo
+ forgejo-view--target-comment-id comment-id)
+ (current-buffer))))
+ (sync-target (with-current-buffer buf
+ (forgejo-view--comment-target-for-sync
+ forgejo-view--ewoc comment-id))))
+ (switch-to-buffer buf)
+ (forgejo-view--goto-target-comment comment-id)
+ (forgejo-issue--sync-detail host owner repo number buf-name nil
sync-target)))
;;; Issue-specific commands
diff --git a/lisp/forgejo-pull.el b/lisp/forgejo-pull.el
index 0ca16415fa..7ca8222ee6 100644
--- a/lisp/forgejo-pull.el
+++ b/lisp/forgejo-pull.el
@@ -297,8 +297,9 @@ accepted so a SHA from a sibling worktree or remote can be
used."
(defun forgejo-pull--render-detail (buf-name host-url owner repo pr-alist
timeline-alists &optional
comment-id)
"Render PR detail into BUF-NAME from alist data.
-HOST-URL is the instance URL. When COMMENT-ID is non-nil, jump to
-that comment after rendering."
+HOST-URL is the instance URL. OWNER and REPO identify the repository.
+PR-ALIST is the pull request data and TIMELINE-ALISTS is its timeline.
+When COMMENT-ID is non-nil, jump to that comment after rendering."
(forgejo-view--render-detail buf-name host-url owner repo pr-alist
timeline-alists
#'forgejo-pull-view-mode
@@ -321,9 +322,10 @@ Each review-type event gains a review_state key looked up
by review_id."
timeline)))
(defun forgejo-pull--sync-detail (host owner repo number buf-name
- &optional restore-line)
- "Sync PR NUMBER from API in background, re-render BUF-NAME if changed.
-When RESTORE-LINE is non-nil, go to that line after re-rendering."
+ &optional restore-line comment-id)
+ "Sync PR NUMBER from API for HOST OWNER/REPO into BUF-NAME.
+When RESTORE-LINE is non-nil, go to that line after re-rendering.
+When COMMENT-ID is non-nil, jump to that comment after re-rendering."
(let ((host-url (forgejo--host-url-for-hostname host))
(pr-endpoint (format "repos/%s/%s/pulls/%d" owner repo number))
(tl-endpoint (format "repos/%s/%s/issues/%d/timeline"
@@ -349,7 +351,7 @@ When RESTORE-LINE is non-nil, go to that line after
re-rendering."
(format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
(forgejo-view--re-render
buf-name host-url host owner repo number
- #'forgejo-pull--render-detail restore-line)
+ #'forgejo-pull--render-detail restore-line comment-id)
(forgejo-view--sync-reactions
host-url host owner repo number buf-name enriched)
(let ((tl-alists (mapcar #'forgejo-db--row-to-timeline-alist
@@ -381,22 +383,26 @@ When COMMENT-ID is non-nil, jump to that comment after
rendering."
(buf-name (format "*forgejo-pr: %s/%s#%d*" owner repo number))
(pr-alist (forgejo-db-get-issue host owner repo number))
(tl-rows (forgejo-db-get-timeline host owner repo number))
- (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
- (if pr-alist
- (switch-to-buffer
- (forgejo-pull--render-detail buf-name host-url owner repo
- pr-alist tl-alists comment-id))
- (with-current-buffer (get-buffer-create buf-name)
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert "Loading..."))
- (forgejo-pull-view-mode)
- (setq forgejo-repo--host host-url
- forgejo-repo--owner owner
- forgejo-repo--name repo
- forgejo-view--target-comment-id comment-id)
- (switch-to-buffer (current-buffer))))
- (forgejo-pull--sync-detail host owner repo number buf-name)))
+ (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows))
+ (buf (if pr-alist
+ (forgejo-pull--render-detail buf-name host-url owner repo
+ pr-alist tl-alists comment-id)
+ (with-current-buffer (get-buffer-create buf-name)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "Loading..."))
+ (forgejo-pull-view-mode)
+ (setq forgejo-repo--host host-url
+ forgejo-repo--owner owner
+ forgejo-repo--name repo
+ forgejo-view--target-comment-id comment-id)
+ (current-buffer))))
+ (sync-target (with-current-buffer buf
+ (forgejo-view--comment-target-for-sync
+ forgejo-view--ewoc comment-id))))
+ (switch-to-buffer buf)
+ (forgejo-view--goto-target-comment comment-id)
+ (forgejo-pull--sync-detail host owner repo number buf-name nil
sync-target)))
;;; PR-specific commands
diff --git a/lisp/forgejo-view.el b/lisp/forgejo-view.el
index ace2dc812e..1c53c1606a 100644
--- a/lisp/forgejo-view.el
+++ b/lisp/forgejo-view.el
@@ -63,7 +63,7 @@
(defvar-local forgejo-view--sync-fn nil
"Function to sync the current item from the API.
-Called with (HOST OWNER REPO NUMBER BUF-NAME &optional RESTORE-LINE).")
+Called with (HOST OWNER REPO NUMBER BUF-NAME &optional RESTORE-LINE
COMMENT-ID).")
(defvar-local forgejo-view--browse-fn nil
"Function to open the current item in the browser.
@@ -277,6 +277,15 @@ COMMENT-ID is nil unless URL has a #issuecomment-N
fragment."
(and (match-string 4 url)
(string-to-number (match-string 4 url))))))
+(defun forgejo-view--url-target-at-point ()
+ "Return (HOST-URL OWNER REPO NUMBER COMMENT-ID) for Forgejo URL at point."
+ (when-let* ((url (forgejo-buffer--url-at-point))
+ (parsed (forgejo-view--parse-forgejo-url url))
+ (url-obj (url-generic-parse-url url))
+ (protocol (url-type url-obj))
+ (host (url-host url-obj)))
+ (cons (format "%s://%s" protocol host) parsed)))
+
(defun forgejo-view-browse-url (url &rest _args)
"Open URL in forgejo.el if it's a Forgejo issue/PR, else in browser."
(if-let* ((parsed (forgejo-view--parse-forgejo-url url))
@@ -343,12 +352,16 @@ in forgejo.el buffers rather than the web browser."
"Follow the link at point.
Handles #N issue/PR refs and markdown URLs."
(interactive)
- (cond
- ((get-text-property (point) 'forgejo-ref-number)
- (forgejo-view-follow-ref))
- ((forgejo-buffer--url-at-point)
- (forgejo-view-browse-url (forgejo-buffer--url-at-point)))
- (t (user-error "No link at point"))))
+ (if-let* ((target (forgejo-view--url-target-at-point)))
+ (pcase-let ((`(,host-url ,owner ,repo ,number ,comment-id) target))
+ (let ((forgejo-repo--host host-url))
+ (forgejo-view-item owner repo number comment-id)))
+ (cond
+ ((get-text-property (point) 'forgejo-ref-number)
+ (forgejo-view-follow-ref))
+ ((forgejo-buffer--url-at-point)
+ (forgejo-view-browse-url (forgejo-buffer--url-at-point)))
+ (t (user-error "No link at point")))))
(declare-function forgejo-review-open-thread "forgejo-review.el" ())
@@ -418,6 +431,12 @@ Handles #N issue/PR refs and markdown URLs."
"Return the data plist of the EWOC node at point, or nil."
(forgejo-buffer--node-at-point forgejo-view--ewoc))
+(defun forgejo-view--comment-id-at-point ()
+ "Return the id of the comment node at point, or nil."
+ (when-let* ((node (forgejo-view--node-at-point)))
+ (and (eq (plist-get node :type) 'comment)
+ (plist-get node :id))))
+
;;; Item routing
(declare-function forgejo-issue-view "forgejo-issue.el"
@@ -436,21 +455,23 @@ DB-normalised form (`pull_request' is t or nil)."
"View issue or PR NUMBER in OWNER/REPO.
Checks the DB first, then the API if uncached. When COMMENT-ID is
non-nil, jump to that comment after the detail view renders."
- (let* ((host (url-host (url-generic-parse-url forgejo-repo--host)))
- (cached (forgejo-db-get-issue host owner repo number)))
+ (let* ((host-url forgejo-repo--host)
+ (host (url-host (url-generic-parse-url host-url)))
+ (cached (forgejo-db-get-issue host owner repo number))
+ (view (lambda (item)
+ (let ((forgejo-repo--host host-url))
+ (if (forgejo-view--pr-p item)
+ (forgejo-pull-view owner repo number comment-id)
+ (forgejo-issue-view owner repo number comment-id))))))
(if cached
- (if (forgejo-view--pr-p cached)
- (forgejo-pull-view owner repo number comment-id)
- (forgejo-issue-view owner repo number comment-id))
+ (funcall view cached)
(forgejo-api-get
- forgejo-repo--host
+ host-url
(format "repos/%s/%s/issues/%d" owner repo number) nil
(lambda (data _headers)
(when data
(forgejo-db-save-issues host owner repo (list data))
- (if (forgejo-view--pr-p data)
- (forgejo-pull-view owner repo number comment-id)
- (forgejo-issue-view owner repo number comment-id))))))))
+ (funcall view data)))))))
;;; List-view format
@@ -499,26 +520,44 @@ LIST-MODE is the major mode symbol for the list buffer."
;;; Detail-view rendering
+(defun forgejo-view--comment-node (ewoc comment-id)
+ "Return the EWOC comment node for COMMENT-ID, or nil."
+ (and ewoc
+ (cl-loop for node = (ewoc-nth ewoc 0) then (ewoc-next ewoc node)
+ while node
+ for data = (ewoc-data node)
+ when (and (eq 'comment (plist-get data :type))
+ (eql comment-id (plist-get data :id)))
+ return node)))
+
(defun forgejo-view--goto-comment (ewoc comment-id)
- "Move point to the EWOC node for COMMENT-ID, or `point-min'.
+ "Move point to the EWOC comment node for COMMENT-ID.
Returns non-nil when a matching comment node was found."
- (or (cl-loop for node = (ewoc-nth ewoc 0) then (ewoc-next ewoc node)
- while node
- for data = (ewoc-data node)
- when (and (eq 'comment (plist-get data :type))
- (eql comment-id (plist-get data :id)))
- return (progn (goto-char (ewoc-location node)) t))
- (progn (goto-char (point-min)) nil)))
+ (when-let* ((node (forgejo-view--comment-node ewoc comment-id)))
+ (ewoc-goto-node ewoc node)
+ t))
+
+(defun forgejo-view--goto-target-comment (comment-id)
+ "Move point to COMMENT-ID when non-nil."
+ (and comment-id
+ (forgejo-view--goto-comment forgejo-view--ewoc comment-id)))
+
+(defun forgejo-view--comment-target-for-sync (ewoc comment-id)
+ "Return COMMENT-ID when EWOC does not already contain it."
+ (and comment-id
+ (not (forgejo-view--comment-node ewoc comment-id))
+ comment-id))
(defun forgejo-view--render-detail (buf-name host-url owner repo item-alist
timeline-alists view-mode-fn
sync-fn browse-fn
&optional comment-id)
"Render detail view into BUF-NAME from alist data.
-HOST-URL is the full instance URL. ITEM-ALIST is the issue or PR data.
-TIMELINE-ALISTS is the timeline. VIEW-MODE-FN activates the major mode.
-SYNC-FN and BROWSE-FN are stored as buffer-locals for action commands.
-When COMMENT-ID is non-nil, jump to that comment after rendering.
+HOST-URL is the full instance URL. OWNER and REPO identify the repo.
+ITEM-ALIST is the issue or PR data. TIMELINE-ALISTS is the timeline.
+VIEW-MODE-FN activates the major mode. SYNC-FN and BROWSE-FN are stored
+as buffer-locals for action commands. When COMMENT-ID is non-nil, jump
+to that comment after rendering.
First call into a buffer does a full paint. Subsequent calls reconcile
the existing EWOC against the new nodes, avoiding any work for
@@ -532,13 +571,13 @@ unchanged nodes."
(number (alist-get 'number item-alist)))
(if (and existing-ewoc (ewoc-buffer existing-ewoc))
(forgejo-view--reconcile-into existing-buf nodes item-alist
- host owner repo number)
+ host owner repo number comment-id)
(forgejo-view--first-paint buf-name nodes item-alist host-url host
owner repo number view-mode-fn
sync-fn browse-fn comment-id))))
(defun forgejo-view--resolve-sticky-target (buf-name comment-id)
- "Pick the sticky comment id for BUF-NAME, falling back to COMMENT-ID."
+ "Return COMMENT-ID or BUF-NAME's existing sticky comment target."
(or comment-id
(and (get-buffer buf-name)
(buffer-local-value 'forgejo-view--target-comment-id
@@ -547,7 +586,8 @@ unchanged nodes."
(defun forgejo-view--install-detail-locals (host-url owner repo item-alist
sync-fn browse-fn
sticky-target)
- "Set the detail-view buffer-local variables in the current buffer."
+ "Set detail locals for HOST-URL OWNER REPO ITEM-ALIST.
+SYNC-FN, BROWSE-FN, and STICKY-TARGET are stored as buffer-local state."
(setq forgejo-repo--host host-url
forgejo-repo--owner owner
forgejo-repo--name repo
@@ -568,7 +608,9 @@ Stores the EWOC in `forgejo-view--ewoc' and returns it."
(defun forgejo-view--first-paint (buf-name nodes item-alist host-url host
owner repo number view-mode-fn
sync-fn browse-fn comment-id)
- "Build the EWOC from scratch in BUF-NAME and paint NODES."
+ "Build a fresh detail view in BUF-NAME from NODES and ITEM-ALIST.
+HOST-URL, HOST, OWNER, REPO, and NUMBER identify the issue or PR.
+VIEW-MODE-FN, SYNC-FN, BROWSE-FN, and COMMENT-ID configure the view."
(forgejo-buffer--fontify-node-bodies nodes)
(let ((sticky-target (forgejo-view--resolve-sticky-target buf-name
comment-id)))
(with-current-buffer (get-buffer-create buf-name)
@@ -582,16 +624,18 @@ Stores the EWOC in `forgejo-view--ewoc' and returns it."
(forgejo-buffer--update-reactions
forgejo-view--ewoc host owner repo number)
(setq header-line-format (forgejo-buffer--header-line item-alist))
- (if sticky-target
- (forgejo-view--goto-comment forgejo-view--ewoc sticky-target)
+ (unless (and sticky-target
+ (forgejo-view--goto-target-comment sticky-target))
(goto-char (point-min)))
(set-buffer-modified-p nil)
(current-buffer))))
-(defun forgejo-view--reconcile-into (buf nodes item-alist host owner repo
number)
- "Reconcile EWOC in BUF against NODES; refresh dependent state.
-Point is left untouched. Updates `forgejo-view--data', reactions, and
-the header line only if it changed."
+(defun forgejo-view--reconcile-into (buf nodes item-alist host owner repo
number
+ &optional comment-id)
+ "Reconcile EWOC in BUF against NODES for ITEM-ALIST.
+HOST, OWNER, REPO, and NUMBER identify the issue or PR. Point is left
+untouched unless COMMENT-ID is non-nil. Updates `forgejo-view--data',
+reactions, and the header line only if it changed."
(with-current-buffer buf
(let ((inhibit-read-only t))
(setq forgejo-view--data item-alist)
@@ -601,7 +645,8 @@ the header line only if it changed."
(let ((new-header (forgejo-buffer--header-line item-alist)))
(unless (equal header-line-format new-header)
(setq header-line-format new-header)))
- (set-buffer-modified-p nil))
+ (set-buffer-modified-p nil)
+ (forgejo-view--goto-target-comment comment-id))
buf))
;;; Reaction sync
@@ -645,17 +690,18 @@ event alists (used to extract comment IDs)."
;;; Re-render from DB
(defun forgejo-view--re-render (buf-name host-url host owner repo number
- render-fn &optional restore-line)
+ render-fn &optional restore-line
comment-id)
"Re-render detail buffer BUF-NAME from fresh DB data.
-HOST-URL is the instance. HOST is the hostname.
-RENDER-FN is called with (BUF-NAME HOST-URL OWNER REPO ITEM TIMELINE).
-Restores point to RESTORE-LINE if given."
+HOST-URL is the instance. HOST is the hostname. OWNER, REPO, and
+NUMBER identify the issue or PR. RENDER-FN is called with the cached
+item, timeline, and COMMENT-ID. COMMENT-ID takes precedence over
+RESTORE-LINE when both are non-nil."
(when (buffer-live-p (get-buffer buf-name))
(let* ((issue (forgejo-db-get-issue host owner repo number))
(tl-rows (forgejo-db-get-timeline host owner repo number))
(tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
- (funcall render-fn buf-name host-url owner repo issue tl-alists)
- (when restore-line
+ (funcall render-fn buf-name host-url owner repo issue tl-alists
comment-id)
+ (when (and restore-line (not comment-id))
(with-current-buffer buf-name
(goto-char (point-min))
(forward-line (1- restore-line)))))))
@@ -996,18 +1042,19 @@ Only types you have already reacted with show the remove
option."
"Force sync the current detail view.
Refreshes wall-clock-dependent display (relative timestamps) from
cached data immediately, then fires the async sync. Per-node
-invalidation keeps point on the current comment; the reconcile path
-preserves it from there."
+invalidation keeps point on the current comment; the sync render uses
+that comment id as its explicit target."
(interactive)
(when-let* ((data forgejo-view--data)
(number (alist-get 'number data))
(sync-fn forgejo-view--sync-fn))
- (when forgejo-view--ewoc
- (forgejo-buffer--ewoc-invalidate-all forgejo-view--ewoc))
- (let ((host (url-host (url-generic-parse-url forgejo-repo--host))))
- (funcall sync-fn host forgejo-repo--owner
- forgejo-repo--name number
- (buffer-name) nil))))
+ (let ((comment-id (forgejo-view--comment-id-at-point)))
+ (when forgejo-view--ewoc
+ (forgejo-buffer--ewoc-invalidate-all forgejo-view--ewoc))
+ (let ((host (url-host (url-generic-parse-url forgejo-repo--host))))
+ (funcall sync-fn host forgejo-repo--owner
+ forgejo-repo--name number
+ (buffer-name) nil comment-id)))))
(defun forgejo-view-copy-url ()
"Copy the URL at point to the kill ring."
diff --git a/tests/forgejo-test-issue.el b/tests/forgejo-test-issue.el
index e6d7e2d3c4..13e4d2ed53 100644
--- a/tests/forgejo-test-issue.el
+++ b/tests/forgejo-test-issue.el
@@ -9,6 +9,8 @@
(require 'ert)
(require 'cl-lib)
+(defvar forgejo-markdown-mode)
+
(setq forgejo-markdown-mode 'text-mode)
(require 'forgejo-issue)
@@ -88,5 +90,97 @@
(should-not close-called)
(should-not sync-called))))
+;;; Group 4: Detail view entry
+
+(defun forgejo-test-issue--issue-alist ()
+ "Return an issue alist for detail view tests."
+ '((number . 1)
+ (title . "Test issue")
+ (state . "open")
+ (body . "")
+ (user . ((login . "alice")))
+ (created_at . "2026-01-01T00:00:00Z")
+ (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-issue--comment-alist (id)
+ "Return a timeline comment alist with ID."
+ `((type . "comment")
+ (id . ,id)
+ (body . ,(format "Comment %d" id))
+ (user . ((login . "commenter")))
+ (created_at . "2026-01-01T00:00:00Z")
+ (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-issue--timeline (&rest ids)
+ "Return timeline comment events for IDS."
+ (mapcar #'forgejo-test-issue--comment-alist ids))
+
+(ert-deftest forgejo-test-issue-view-passes-missing-comment-id-to-sync ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ sync-args)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) '((number . 1))))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'forgejo-issue--render-detail)
+ (lambda (buf-name &rest _args) (get-buffer-create
buf-name)))
+ ((symbol-function 'forgejo-issue--sync-detail)
+ (lambda (&rest args) (setq sync-args args))))
+ (forgejo-issue-view "owner" "repo" 1 99)
+ (should (equal sync-args
+ '("codeberg.org" "owner" "repo" 1
+ "*forgejo-issue: owner/repo#1*" nil 99))))
+ (when-let* ((buf (get-buffer "*forgejo-issue: owner/repo#1*")))
+ (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-issue-view-omits-present-comment-id-from-sync ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ sync-args)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) '((number . 1))))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'forgejo-issue--render-detail)
+ (lambda (buf-name &rest _args)
+ (with-current-buffer (get-buffer-create buf-name)
+ (setq forgejo-view--ewoc (ewoc-create #'ignore nil nil
t))
+ (ewoc-enter-last forgejo-view--ewoc '(:type comment :id
99))
+ (current-buffer))))
+ ((symbol-function 'forgejo-issue--sync-detail)
+ (lambda (&rest args) (setq sync-args args))))
+ (forgejo-issue-view "owner" "repo" 1 99)
+ (should (equal sync-args
+ '("codeberg.org" "owner" "repo" 1
+ "*forgejo-issue: owner/repo#1*" nil nil))))
+ (when-let* ((buf (get-buffer "*forgejo-issue: owner/repo#1*")))
+ (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-issue-view-jumps-existing-buffer-window ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ (buf-name "*forgejo-issue: owner/repo#1*")
+ (other (get-buffer-create "*forgejo-test-issue-other*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) (forgejo-test-issue--issue-alist)))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) (forgejo-test-issue--timeline 10 20)))
+ ((symbol-function 'forgejo-db--row-to-timeline-alist)
+ #'identity)
+ ((symbol-function 'forgejo-buffer--update-reactions)
+ #'ignore)
+ ((symbol-function 'forgejo-issue--sync-detail)
+ #'ignore))
+ (forgejo-issue-view "owner" "repo" 1)
+ (forgejo-view--goto-comment forgejo-view--ewoc 10)
+ (switch-to-buffer other)
+ (forgejo-issue-view "owner" "repo" 1 20)
+ (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+ (when-let* ((buf (get-buffer buf-name)))
+ (kill-buffer buf))
+ (when (buffer-live-p other)
+ (kill-buffer other)))))
+
(provide 'forgejo-test-issue)
;;; forgejo-test-issue.el ends here
diff --git a/tests/forgejo-test-pull.el b/tests/forgejo-test-pull.el
index e32d8dfa9e..539b3a0378 100644
--- a/tests/forgejo-test-pull.el
+++ b/tests/forgejo-test-pull.el
@@ -9,6 +9,8 @@
(require 'ert)
(require 'cl-lib)
+(defvar forgejo-markdown-mode)
+
(setq forgejo-markdown-mode 'text-mode)
(require 'forgejo-pull)
@@ -88,5 +90,98 @@
(should-not close-called)
(should-not sync-called))))
+;;; Group 4: Detail view entry
+
+(defun forgejo-test-pull--pr-alist ()
+ "Return a PR alist for detail view tests."
+ '((number . 1)
+ (title . "Test PR")
+ (state . "open")
+ (body . "")
+ (pull_request . t)
+ (user . ((login . "alice")))
+ (created_at . "2026-01-01T00:00:00Z")
+ (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-pull--comment-alist (id)
+ "Return a timeline comment alist with ID."
+ `((type . "comment")
+ (id . ,id)
+ (body . ,(format "Comment %d" id))
+ (user . ((login . "commenter")))
+ (created_at . "2026-01-01T00:00:00Z")
+ (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-pull--timeline (&rest ids)
+ "Return timeline comment events for IDS."
+ (mapcar #'forgejo-test-pull--comment-alist ids))
+
+(ert-deftest forgejo-test-pull-view-passes-missing-comment-id-to-sync ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ sync-args)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) '((number . 1))))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'forgejo-pull--render-detail)
+ (lambda (buf-name &rest _args) (get-buffer-create
buf-name)))
+ ((symbol-function 'forgejo-pull--sync-detail)
+ (lambda (&rest args) (setq sync-args args))))
+ (forgejo-pull-view "owner" "repo" 1 99)
+ (should (equal sync-args
+ '("codeberg.org" "owner" "repo" 1
+ "*forgejo-pr: owner/repo#1*" nil 99))))
+ (when-let* ((buf (get-buffer "*forgejo-pr: owner/repo#1*")))
+ (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-pull-view-omits-present-comment-id-from-sync ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ sync-args)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) '((number . 1))))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'forgejo-pull--render-detail)
+ (lambda (buf-name &rest _args)
+ (with-current-buffer (get-buffer-create buf-name)
+ (setq forgejo-view--ewoc (ewoc-create #'ignore nil nil
t))
+ (ewoc-enter-last forgejo-view--ewoc '(:type comment :id
99))
+ (current-buffer))))
+ ((symbol-function 'forgejo-pull--sync-detail)
+ (lambda (&rest args) (setq sync-args args))))
+ (forgejo-pull-view "owner" "repo" 1 99)
+ (should (equal sync-args
+ '("codeberg.org" "owner" "repo" 1
+ "*forgejo-pr: owner/repo#1*" nil nil))))
+ (when-let* ((buf (get-buffer "*forgejo-pr: owner/repo#1*")))
+ (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-pull-view-jumps-existing-buffer-window ()
+ (let ((forgejo-repo--host "https://codeberg.org")
+ (buf-name "*forgejo-pr: owner/repo#1*")
+ (other (get-buffer-create "*forgejo-test-pull-other*")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) (forgejo-test-pull--pr-alist)))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) (forgejo-test-pull--timeline 10 20)))
+ ((symbol-function 'forgejo-db--row-to-timeline-alist)
+ #'identity)
+ ((symbol-function 'forgejo-buffer--update-reactions)
+ #'ignore)
+ ((symbol-function 'forgejo-pull--sync-detail)
+ #'ignore))
+ (forgejo-pull-view "owner" "repo" 1)
+ (forgejo-view--goto-comment forgejo-view--ewoc 10)
+ (switch-to-buffer other)
+ (forgejo-pull-view "owner" "repo" 1 20)
+ (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+ (when-let* ((buf (get-buffer buf-name)))
+ (kill-buffer buf))
+ (when (buffer-live-p other)
+ (kill-buffer other)))))
+
(provide 'forgejo-test-pull)
;;; forgejo-test-pull.el ends here
diff --git a/tests/forgejo-test-view.el b/tests/forgejo-test-view.el
index 8d2494c965..4085c8f0ed 100644
--- a/tests/forgejo-test-view.el
+++ b/tests/forgejo-test-view.el
@@ -7,6 +7,10 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
+
+(defvar forgejo-markdown-mode)
+(defvar forgejo-repo--host)
(setq forgejo-markdown-mode 'text-mode)
(require 'forgejo-view)
@@ -38,5 +42,205 @@
"https://example.com/some/page"))
(should-not (forgejo-view--parse-forgejo-url nil)))
+;;; Group 2: EWOC comment navigation
+
+(defun forgejo-test-view--issue-alist ()
+ "Return a minimal issue alist for detail rendering."
+ '((number . 1)
+ (title . "Issue")
+ (state . "open")
+ (body . "")
+ (user . ((login . "user")))
+ (created_at . "2026-01-01T00:00:00Z")
+ (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-view--comment-alist (id)
+ "Return a minimal comment timeline event for ID."
+ `((id . ,id)
+ (type . "comment")
+ (body . ,(format "Comment %d" id))
+ (user . ((login . "commenter")))
+ (created_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-view--timeline (&rest ids)
+ "Return timeline comment events for IDS."
+ (mapcar #'forgejo-test-view--comment-alist ids))
+
+(ert-deftest forgejo-test-view-goto-comment-finds-ewoc-node ()
+ (with-temp-buffer
+ (let ((ewoc (ewoc-create
+ (lambda (node) (insert (format "%S\n" node))) nil nil t)))
+ (ewoc-enter-last ewoc '(:type event :id 20))
+ (ewoc-enter-last ewoc '(:type comment :id 10))
+ (ewoc-enter-last ewoc '(:type comment :id 20))
+ (should (forgejo-view--goto-comment ewoc 20))
+ (should (equal (ewoc-data (ewoc-locate ewoc))
+ '(:type comment :id 20))))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-present ()
+ (with-temp-buffer
+ (let ((ewoc (ewoc-create #'ignore nil nil t)))
+ (ewoc-enter-last ewoc '(:type comment :id 10))
+ (should-not (forgejo-view--comment-target-for-sync ewoc 10)))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-missing ()
+ (with-temp-buffer
+ (let ((ewoc (ewoc-create #'ignore nil nil t)))
+ (ewoc-enter-last ewoc '(:type comment :id 10))
+ (should (eql 20 (forgejo-view--comment-target-for-sync ewoc 20))))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-nil-ewoc ()
+ (should (eql 20 (forgejo-view--comment-target-for-sync nil 20))))
+
+(ert-deftest forgejo-test-view-render-detail-comment-id-jumps-first-paint ()
+ (let ((buf-name "*forgejo-test-first-paint-jump*"))
+ (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+ (let ((buf (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode #'ignore #'ignore 20)))
+ (unwind-protect
+ (with-current-buffer buf
+ (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+ (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-render-detail-comment-id-jumps-existing-buffer
()
+ (let ((buf-name "*forgejo-test-existing-buffer-jump*"))
+ (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+ (let ((buf (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode #'ignore #'ignore)))
+ (unwind-protect
+ (with-current-buffer
+ (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode #'ignore #'ignore 20)
+ (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+ (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-render-detail-keeps-point-without-comment-id ()
+ (let ((buf-name "*forgejo-test-existing-buffer-keep-point*")
+ point-before)
+ (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+ (let ((buf (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode #'ignore #'ignore)))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq forgejo-view--target-comment-id 20)
+ (forgejo-view--goto-comment forgejo-view--ewoc 10)
+ (setq point-before (point))
+ (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode #'ignore #'ignore)
+ (should (eql point-before (point))))
+ (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-url-target-at-point-includes-comment-id ()
+ (with-temp-buffer
+ (insert "https://codeberg.org/guix/guix/issues/5125#issuecomment-9233177")
+ (goto-char (point-min))
+ (should (equal (forgejo-view--url-target-at-point)
+ '("https://codeberg.org" "guix" "guix" 5125 9233177)))))
+
+(ert-deftest forgejo-test-view-follow-link-passes-url-comment-id ()
+ (let (called)
+ (cl-letf (((symbol-function 'forgejo-view-item)
+ (lambda (&rest args)
+ (setq called (cons forgejo-repo--host args)))))
+ (with-temp-buffer
+ (insert
"https://codeberg.org/guix/guix/issues/5125#issuecomment-9233177")
+ (goto-char (point-min))
+ (forgejo-view-follow-link)
+ (should (equal called
+ '("https://codeberg.org" "guix" "guix" 5125
9233177)))))))
+
+(ert-deftest forgejo-test-view-refresh-passes-comment-at-point ()
+ (let ((buf-name "*forgejo-test-refresh-comment*")
+ called)
+ (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+ (let ((buf (forgejo-view--render-detail
+ buf-name "https://codeberg.org" "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10 20)
+ #'fundamental-mode
+ (lambda (&rest args) (setq called args))
+ #'ignore)))
+ (unwind-protect
+ (with-current-buffer buf
+ (setq forgejo-view--target-comment-id 20)
+ (forgejo-view--goto-comment forgejo-view--ewoc 10)
+ (forgejo-view-refresh)
+ (should (equal called
+ '("codeberg.org" "owner" "repo" 1
+ "*forgejo-test-refresh-comment*" nil 10))))
+ (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-re-render-passes-comment-id ()
+ (let ((buf (get-buffer-create "*forgejo-test-re-render-comment*"))
+ called)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) (forgejo-test-view--issue-alist)))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) (forgejo-test-view--timeline 10)))
+ ((symbol-function 'forgejo-db--row-to-timeline-alist)
+ #'identity))
+ (forgejo-view--re-render
+ (buffer-name buf) "https://codeberg.org" "codeberg.org"
+ "owner" "repo" 1
+ (lambda (&rest args) (setq called args)) nil 10)
+ (should (equal called
+ (list (buffer-name buf) "https://codeberg.org"
+ "owner" "repo"
+ (forgejo-test-view--issue-alist)
+ (forgejo-test-view--timeline 10)
+ 10))))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-view-re-render-comment-id-beats-restore-line ()
+ (let ((buf (get-buffer-create "*forgejo-test-re-render-precedence*"))
+ restored)
+ (unwind-protect
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) (forgejo-test-view--issue-alist)))
+ ((symbol-function 'forgejo-db-get-timeline)
+ (lambda (&rest _args) nil)))
+ (with-current-buffer buf
+ (erase-buffer)
+ (insert "line 1\nline 2\n")
+ (goto-char (point-min)))
+ (forgejo-view--re-render
+ (buffer-name buf) "https://codeberg.org" "codeberg.org"
+ "owner" "repo" 1 #'ignore 2 10)
+ (with-current-buffer buf
+ (setq restored (line-number-at-pos)))
+ (should (= restored 1)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-view-item-keeps-host-in-async-callback ()
+ (let ((forgejo-repo--host "https://alt.example")
+ callback seen-host)
+ (cl-letf (((symbol-function 'forgejo-db-get-issue)
+ (lambda (&rest _args) nil))
+ ((symbol-function 'forgejo-api-get)
+ (lambda (_host _endpoint _params cb) (setq callback cb)))
+ ((symbol-function 'forgejo-db-save-issues) #'ignore)
+ ((symbol-function 'forgejo-issue-view)
+ (lambda (&rest _args) (setq seen-host forgejo-repo--host))))
+ (forgejo-view-item "owner" "repo" 1 99)
+ (let ((forgejo-repo--host "https://wrong.example"))
+ (funcall callback '((number . 1)) nil))
+ (should (equal seen-host "https://alt.example")))))
+
(provide 'forgejo-test-view)
;;; forgejo-test-view.el ends here