feat: implement review workflow

- identification and collection of review comments
- api call for creating review
- keybinds for submitting review
This commit is contained in:
Lucas Sta Maria 2025-08-24 00:12:56 +08:00
parent 1e8e7df3a8
commit aa4749c648
Signed by: lucas
GPG key ID: F07FB16A826E3B47
3 changed files with 335 additions and 2 deletions

View file

@ -35,6 +35,7 @@ Returns the token string if found, nil otherwise."
(defun ghpr--parse-api-pr (pr) (defun ghpr--parse-api-pr (pr)
"Parse a single pull request object, keeping only essential fields." "Parse a single pull request object, keeping only essential fields."
(let ((base (alist-get 'base pr)) (let ((base (alist-get 'base pr))
(head (alist-get 'head pr))
(user (alist-get 'user pr))) (user (alist-get 'user pr)))
`((url . ,(alist-get 'url pr)) `((url . ,(alist-get 'url pr))
(id . ,(alist-get 'id pr)) (id . ,(alist-get 'id pr))
@ -46,7 +47,9 @@ Returns the token string if found, nil otherwise."
(title . ,(alist-get 'title pr)) (title . ,(alist-get 'title pr))
(body . ,(alist-get 'body pr)) (body . ,(alist-get 'body pr))
(username . ,(alist-get 'login user)) (username . ,(alist-get 'login user))
(author . ,(alist-get 'login user))
(base_sha . ,(alist-get 'sha base)) (base_sha . ,(alist-get 'sha base))
(head_sha . ,(alist-get 'sha head))
(merge_commit_sha . ,(alist-get 'merge_commit_sha pr))))) (merge_commit_sha . ,(alist-get 'merge_commit_sha pr)))))
(defun ghpr--parse-api-pr-list (pr-list) (defun ghpr--parse-api-pr-list (pr-list)
@ -95,6 +98,48 @@ Returns a list of pull request objects on success, nil on failure."
(message "Error fetching diff: %s" error-thrown))))) (message "Error fetching diff: %s" error-thrown)))))
result)) result))
(defun ghpr--create-review (repo-name pr-number commit-id body event comments)
"Create a review for PR-NUMBER in REPO-NAME.
COMMIT-ID is the SHA of the commit to review.
BODY is the overall review comment.
EVENT should be 'APPROVE', 'REQUEST_CHANGES', or 'COMMENT'.
COMMENTS is a list of inline comments, each with keys: path, position, body.
Returns t on success, nil on failure."
(let ((token (ghpr--get-token))
(url (format "https://api.github.com/repos/%s/pulls/%s/reviews" repo-name pr-number))
(result nil)
(payload `((commit_id . ,commit-id)
(body . ,body)
(event . ,event)
(comments . ,(vconcat comments)))))
(when token
(request url
:type "POST"
:headers `(("Accept" . "application/vnd.github+json")
("Authorization" . ,(format "Bearer %s" token))
("X-GitHub-Api-Version" . "2022-11-28")
("Content-Type" . "application/json"))
:data (json-encode payload)
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(setq result t)
(message "Review created successfully")))
:error (cl-function
(lambda (&key data error-thrown response &allow-other-keys)
(let ((status-code (when response (request-response-status-code response)))
(error-body (when data (json-encode data)))
(errors (when data (alist-get 'errors data))))
(message "Error creating review (HTTP %s): %s"
(or status-code "unknown")
error-thrown)
(when errors
(--each errors
(message "Error creating review (HTTP %s): %s" (or status-code "unknown") it))))))))
result))
(provide 'ghpr-api) (provide 'ghpr-api)
;;; ghpr-api.el ends here ;;; ghpr-api.el ends here

View file

@ -32,6 +32,7 @@
;;; Code: ;;; Code:
(require 'request) (require 'request)
(require 'magit-git)
(require 'ghpr-api) (require 'ghpr-api)
(require 'ghpr-utils) (require 'ghpr-utils)
@ -69,12 +70,30 @@
(defvar-local ghpr--review-diff-content nil (defvar-local ghpr--review-diff-content nil
"Buffer-local variable storing the diff content for the current PR.") "Buffer-local variable storing the diff content for the current PR.")
(defvar-local ghpr--review-pr-metadata nil
"Buffer-local variable storing the PR metadata for the current PR.")
(defvar-local ghpr--review-repo-name nil
"Buffer-local variable storing the repository name for the current PR.")
(defvar ghpr-review-mode-map
(let ((map (make-sparse-keymap))
(prefix-map (make-sparse-keymap)))
(define-key prefix-map (kbd "C-c") 'ghpr-review-comment)
(define-key prefix-map (kbd "C-a") 'ghpr-review-approve)
(define-key prefix-map (kbd "C-r") 'ghpr-review-reject-changes)
(define-key map (kbd "C-c") prefix-map)
map)
"Keymap for `ghpr-review-mode'.")
(define-derived-mode ghpr-review-mode text-mode "GHPR Review" (define-derived-mode ghpr-review-mode text-mode "GHPR Review"
"Major mode for reviewing GitHub pull requests." "Major mode for reviewing GitHub pull requests."
:group 'ghpr :group 'ghpr
:keymap ghpr-review-mode-map
(setq truncate-lines t) (setq truncate-lines t)
(setq font-lock-defaults '(ghpr-review-font-lock-keywords t nil nil nil)) (setq font-lock-defaults '(ghpr-review-font-lock-keywords t nil nil nil))
(font-lock-ensure)) (font-lock-ensure))
(defun ghpr--prefix-line (line) (defun ghpr--prefix-line (line)
"Prefix the LINE with `> '." "Prefix the LINE with `> '."
(concat "> " line)) (concat "> " line))
@ -101,6 +120,8 @@
(diff-content (ghpr--get-diff-content repo-name number)) (diff-content (ghpr--get-diff-content repo-name number))
(contents (ghpr--open-pr/collect-contents pr diff-content))) (contents (ghpr--open-pr/collect-contents pr diff-content)))
(setq ghpr--review-diff-content diff-content) (setq ghpr--review-diff-content diff-content)
(setq ghpr--review-pr-metadata pr)
(setq ghpr--review-repo-name repo-name)
(insert (ghpr--prefix-lines contents)))) (insert (ghpr--prefix-lines contents))))
(defun ghpr--open-pr (pr repo-name) (defun ghpr--open-pr (pr repo-name)
@ -114,6 +135,272 @@
(goto-char (point-min))) (goto-char (point-min)))
(display-buffer buffer))) (display-buffer buffer)))
(defun ghpr--is-comment-line (line)
"Return t if LINE is a user comment (no prefix), nil otherwise."
(not (or (string-prefix-p ">" line)
(string-prefix-p "<" line))))
(defun ghpr--is-code-line (line)
"Return t if LINE is a diff code line (> + or > -), nil otherwise."
(or (string-prefix-p "> +" line)
(string-prefix-p "> -" line)))
(defun ghpr--find-preceding-code-line (lines current-index)
"Find the most recent code line before CURRENT-INDEX in LINES.
Returns the line content and its index as (line . index), or nil if none found."
(let ((index (1- current-index))
(found nil))
(while (and (>= index 0) (not found))
(let ((line (aref lines index)))
(if (ghpr--is-code-line line)
(setq found (cons line index))
(setq index (1- index)))))
found))
(defun ghpr--file-path+sha (lines start-index)
"Find file path and commit SHA by searching backwards from START-INDEX in LINES.
Returns a cons cell (file-path . commit-sha)."
(let ((file-path nil)
(commit-sha nil)
(index start-index))
(while (and (>= index 0) (or (not file-path) (not commit-sha)))
(let ((line (aref lines index)))
(cond
((string-match "^> diff --git a/\\(.+\\) b/" line)
(setq file-path (match-string 1 line)))
((string-match "^> \\+\\+\\+ b/\\(.+\\)$" line)
(setq file-path (match-string 1 line)))
((string-match "^> index \\([a-f0-9]+\\)\\.\\.\\([a-f0-9]+\\)" line)
(setq commit-sha (match-string 2 line))))
(setq index (1- index))))
(cons (when file-path (substring-no-properties file-path))
(when commit-sha (substring-no-properties commit-sha)))))
(defun ghpr--hunk-header (lines start-index)
"Find hunk header by searching backwards from START-INDEX in LINES.
Returns a cons cell (hunk-info . hunk-start-index) or (nil . nil) if not found."
(let ((index start-index)
(hunk-info nil)
(hunk-start nil))
(while (and (>= index 0) (not hunk-info))
(let ((line (aref lines index)))
(when (string-match "^> @@.*@@" line)
(setq hunk-info (substring-no-properties line))
(setq hunk-start index)))
(setq index (1- index)))
(cons hunk-info hunk-start)))
(defun ghpr--line-meta-p (line)
"Determines if the given LINE starts with `>'."
(string-prefix-p ">" line))
(defun ghpr--line-existing-comment-p (line)
"Determines if the given LINE is an existing comment starting with `>'."
(string-prefix-p "<" line))
(defun ghpr--special-line-p (line)
"Determines if the given LINE starts with a special character."
(or (ghpr--line-meta-p line)
(ghpr--line-existing-comment-p line)))
(defun ghpr--calculate-diff-position (lines hunk-start-index code-line-index)
"Calculate diff position from HUNK-START-INDEX to CODE-LINE-INDEX in LINES.
Position 1 is the line just below the first @@ hunk header.
Count only lines that start with > (diff content, not comments).
Returns the diff position as an integer."
(let ((position-count 0)
(index (1+ hunk-start-index)))
(while (<= index code-line-index)
(let ((line (aref lines index)))
(when (ghpr--line-meta-p line)
(setq position-count (1+ position-count))))
(setq index (1+ index)))
position-count))
(defun ghpr--determine-line-type (lines code-line-index)
"Determine the line type (added, removed, or context) for the line at CODE-LINE-INDEX.
Returns a string: \"added\", \"removed\", or \"context\"."
(let ((code-line (aref lines code-line-index)))
(cond
((string-prefix-p "> +" code-line) "added")
((string-prefix-p "> -" code-line) "removed")
(t "context"))))
(defun ghpr--parse-diff-context (lines code-line-index)
"Parse diff context around CODE-LINE-INDEX to extract file path, line type, commit SHA, and diff position.
Returns an alist with file-path, line-type, commit-sha, and diff-position information."
(let* ((file+sha (ghpr--file-path+sha lines code-line-index))
(file-path (car file+sha))
(commit-sha (cdr file+sha))
(hunk-result (ghpr--hunk-header lines code-line-index))
(hunk-info (car hunk-result))
(hunk-start (cdr hunk-result))
(diff-position
(when hunk-start
(ghpr--calculate-diff-position lines hunk-start code-line-index)))
(line-type (ghpr--determine-line-type lines code-line-index)))
`((file-path . ,file-path)
(line-type . ,line-type)
(hunk-info . ,hunk-info)
(commit-sha . ,commit-sha)
(diff-position . ,diff-position))))
(defun ghpr--collect-multiline-comment (lines start-index)
"Collect consecutive comment lines starting from START-INDEX in LINES.
Returns a cons cell (comment-lines . next-index) where comment-lines is a list
of comment lines and next-index is the index after the last comment line."
(let ((comment-lines (list (substring-no-properties (aref lines start-index))))
(next-index (1+ start-index)))
(while (and (< next-index (length lines))
(ghpr--is-comment-line (aref lines next-index)))
(push (substring-no-properties (aref lines next-index)) comment-lines)
(setq next-index (1+ next-index)))
(cons (reverse comment-lines) next-index)))
(defun ghpr--collect-review-body ()
"Collect review body comment from the top of the buffer.
Returns the body text as a string, or nil if no body found.
Body is everything from the start until the first line with special characters (>, <)."
(let* ((lines (vconcat (split-string (buffer-string) "\n")))
(body-lines '())
(index 0))
(while (and (< index (length lines))
(not (ghpr--special-line-p (aref lines index))))
(let ((line (aref lines index)))
(unless (string-empty-p (string-trim line))
(push (substring-no-properties line) body-lines)))
(setq index (1+ index)))
(when body-lines
(string-join (reverse body-lines) "\n"))))
(defun ghpr--skip-review-body (lines)
"Skip to the first line with special characters (>, <) to get past the body.
Returns the index of the first line after the review body."
(let ((index 0)
(past-body nil))
(while (and (< index (length lines)) (not past-body))
(let ((line (aref lines index)))
(when (ghpr--special-line-p line)
(setq past-body t))
(unless past-body
(setq index (1+ index)))))
index))
(defun ghpr--collect-inline-comments-after-body (lines start-index)
"Collect inline comments starting from START-INDEX in LINES.
Returns a list of comment entries with their GitHub API context."
(let ((comments '())
(index start-index))
(while (< index (length lines))
(let ((line (aref lines index)))
(when (ghpr--is-comment-line line)
(let* ((comment-result (ghpr--collect-multiline-comment lines index))
(comment-lines (car comment-result))
(next-index (cdr comment-result))
(comment-entry (ghpr--build-comment-with-context comment-lines index lines)))
(when comment-entry
(push comment-entry comments))
(setq index (1- next-index))))
(setq index (1+ index))))
(reverse comments)))
(defun ghpr--comment-to-api-format (comment)
"Convert a collected comment to GitHub API format.
Errors if the comment is missing required fields or has empty body."
(let ((path (alist-get 'file-path comment))
(position (alist-get 'diff-position comment))
(comment-body (alist-get 'comment comment)))
(unless path
(error "Comment missing file path: %S" comment))
(unless position
(error "Comment missing diff position: %S" comment))
(unless (and comment-body (not (string-empty-p (string-trim comment-body))))
(error "Comment missing or empty body: %S" comment))
`((path . ,path)
(position . ,position)
(body . ,comment-body))))
(defun ghpr--build-comment-with-context (comment-lines comment-start-index lines)
"Build a comment entry with GitHub API context for COMMENT-LINES.
Uses COMMENT-START-INDEX to find the preceding code line in LINES.
Returns an alist with comment, file-path, commit-sha, and diff-position, or nil if no context found."
(let ((preceding-code (ghpr--find-preceding-code-line lines comment-start-index)))
(when preceding-code
(let* ((code-index (cdr preceding-code))
(context (ghpr--parse-diff-context lines code-index))
(full-comment (string-join comment-lines "\n")))
`((comment . ,full-comment)
(file-path . ,(alist-get 'file-path context))
(commit-sha . ,(alist-get 'commit-sha context))
(diff-position . ,(alist-get 'diff-position context)))))))
(defun ghpr--collect-review-comments ()
"Collect all inline review comments from the current buffer.
Returns an alist of comments with their associated diff lines and GitHub API context.
Multi-line comments are grouped together until the next line with angle brackets.
Skips the review body at the top of the buffer."
(let* ((lines (vconcat (split-string (buffer-string) "\n")))
(body-end-index (ghpr--skip-review-body lines)))
(ghpr--collect-inline-comments-after-body lines body-end-index)))
(defun ghpr-collect-review-comments ()
"Interactive command to collect and display review comments from current buffer."
(interactive)
(let ((body (ghpr--collect-review-body))
(comments (ghpr--collect-review-comments)))
(if (or body comments)
(with-output-to-temp-buffer "*GHPR Review Comments*"
(prin1 body)
(prin1 comments))
(message "No review comments found in current buffer."))))
(defun ghpr--submit-review (event)
"Submit a review with the specified EVENT type.
EVENT should be 'COMMENT', 'APPROVE', or 'REQUEST_CHANGES'.
Collects review body and inline comments from current buffer."
(unless ghpr--review-pr-metadata
(error "No PR metadata found in buffer"))
(unless ghpr--review-repo-name
(error "No repository name found in buffer"))
(let* ((body (ghpr--collect-review-body))
(inline-comments (ghpr--collect-review-comments))
(pr-number (alist-get 'number ghpr--review-pr-metadata))
(commit-sha (magit-rev-parse (or (alist-get 'head_sha ghpr--review-pr-metadata)
(alist-get 'merge_commit_sha ghpr--review-pr-metadata))))
(api-comments (mapcar #'ghpr--comment-to-api-format inline-comments)))
(when (and (not body) (not api-comments))
(error "No review body or comments found"))
(when (and (member event '("REQUEST_CHANGES" "COMMENT"))
(or (not body) (string-empty-p (string-trim body))))
(error "Review body is required for %s events" event))
(unless (ghpr--create-review ghpr--review-repo-name
pr-number
commit-sha
(or body "")
event
api-comments)
(message "Failed to submit review"))))
(defun ghpr-review-comment ()
"Submit review comments with COMMENT event."
(interactive)
(ghpr--submit-review "COMMENT"))
(defun ghpr-review-approve ()
"Submit review with APPROVE event."
(interactive)
(ghpr--submit-review "APPROVE"))
(defun ghpr-review-reject-changes ()
"Submit review with REQUEST_CHANGES event."
(interactive)
(ghpr--submit-review "REQUEST_CHANGES"))
(provide 'ghpr-review) (provide 'ghpr-review)
;;; ghpr-review.el ends here ;;; ghpr-review.el ends here

View file

@ -25,8 +25,9 @@
(defun ghpr--pr-summary (pr) (defun ghpr--pr-summary (pr)
"Formats a PR into a summary." "Formats a PR into a summary."
(let* ((number (alist-get 'number pr)) (let* ((number (alist-get 'number pr))
(title (alist-get 'title pr))) (title (alist-get 'title pr))
(format "[#%s] %s" number title))) (author (alist-get 'author pr)))
(format "[#%s] @%s: %s" number author title)))
(defun ghpr--pr-summary-selection (pr) (defun ghpr--pr-summary-selection (pr)
"Formats a PR into a summary for a minibuffer selection." "Formats a PR into a summary for a minibuffer selection."