diff --git a/ghpr-api.el b/ghpr-api.el index ba7834b..94d4060 100644 --- a/ghpr-api.el +++ b/ghpr-api.el @@ -35,6 +35,7 @@ Returns the token string if found, nil otherwise." (defun ghpr--parse-api-pr (pr) "Parse a single pull request object, keeping only essential fields." (let ((base (alist-get 'base pr)) + (head (alist-get 'head pr)) (user (alist-get 'user pr))) `((url . ,(alist-get 'url pr)) (id . ,(alist-get 'id pr)) @@ -46,7 +47,9 @@ Returns the token string if found, nil otherwise." (title . ,(alist-get 'title pr)) (body . ,(alist-get 'body pr)) (username . ,(alist-get 'login user)) + (author . ,(alist-get 'login user)) (base_sha . ,(alist-get 'sha base)) + (head_sha . ,(alist-get 'sha head)) (merge_commit_sha . ,(alist-get 'merge_commit_sha pr))))) (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))))) 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) ;;; ghpr-api.el ends here diff --git a/ghpr-review.el b/ghpr-review.el index 426f88b..3e73d3f 100644 --- a/ghpr-review.el +++ b/ghpr-review.el @@ -32,6 +32,7 @@ ;;; Code: (require 'request) +(require 'magit-git) (require 'ghpr-api) (require 'ghpr-utils) @@ -69,12 +70,30 @@ (defvar-local ghpr--review-diff-content nil "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" "Major mode for reviewing GitHub pull requests." :group 'ghpr + :keymap ghpr-review-mode-map (setq truncate-lines t) (setq font-lock-defaults '(ghpr-review-font-lock-keywords t nil nil nil)) (font-lock-ensure)) + (defun ghpr--prefix-line (line) "Prefix the LINE with `> '." (concat "> " line)) @@ -101,6 +120,8 @@ (diff-content (ghpr--get-diff-content repo-name number)) (contents (ghpr--open-pr/collect-contents pr 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)))) (defun ghpr--open-pr (pr repo-name) @@ -114,6 +135,272 @@ (goto-char (point-min))) (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) ;;; ghpr-review.el ends here diff --git a/ghpr-utils.el b/ghpr-utils.el index a8ad751..76f4410 100644 --- a/ghpr-utils.el +++ b/ghpr-utils.el @@ -25,8 +25,9 @@ (defun ghpr--pr-summary (pr) "Formats a PR into a summary." (let* ((number (alist-get 'number pr)) - (title (alist-get 'title pr))) - (format "[#%s] %s" number title))) + (title (alist-get 'title pr)) + (author (alist-get 'author pr))) + (format "[#%s] @%s: %s" number author title))) (defun ghpr--pr-summary-selection (pr) "Formats a PR into a summary for a minibuffer selection."