From 3a67424fec51012b9f9a3faf64a6e8e754056f73 Mon Sep 17 00:00:00 2001 From: johnybx Date: Tue, 31 Oct 2023 02:58:53 +0100 Subject: [PATCH] Multiline comment and suggestion (#66) This MR adds the ability to leave multi-line comments and suggested changes to an MR. The features are only supported for `diffview` because we plan to deprecate `delta` as a reviewer soon. --- .editorconfig | 119 +++++++++++++++++++ README.md | 7 +- cmd/comment.go | 73 +++++++++--- go.mod | 2 +- go.sum | 2 + lua/gitlab/actions/comment.lua | 157 ++++++++++++++++++------- lua/gitlab/init.lua | 40 ++++--- lua/gitlab/reviewer/delta.lua | 134 ++++++++++++++------- lua/gitlab/reviewer/diffview.lua | 111 +++++++++++++++--- lua/gitlab/reviewer/init.lua | 11 +- lua/gitlab/utils/init.lua | 194 +++++++++++++++++++++++++++---- 11 files changed, 689 insertions(+), 161 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..80df57c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,119 @@ +# see https://github.com/CppCXY/EmmyLuaCodeStyle +[*.lua] +# [basic] + +# optional space/tab +indent_style = space +# if indent_style is space, this is valid +indent_size = 2 +# if indent_style is tab, this is valid +tab_width = 2 +# none/single/double +quote_style = "double" + +continuation_indent = 2 + +#optional keep/never/always/smart +trailing_table_separator = keep + +# keep/remove/remove_table_only/remove_string_only +call_arg_parentheses = keep + +detect_end_of_line = false + +# this will check text end with new line +insert_final_newline = false + +# [space] +space_around_table_field_list = true + +space_before_attribute = true + +space_before_function_open_parenthesis = false + +space_before_function_call_open_parenthesis = false + +space_before_closure_open_parenthesis = false + +space_before_function_call_single_arg = true + +space_before_open_square_bracket = false + +space_inside_function_call_parentheses = false + +space_inside_function_param_list_parentheses = false + +space_inside_square_brackets = false + +# like t[#t+1] = 1 +space_around_table_append_operator = true + +ignore_spaces_inside_function_call = false + +space_before_inline_comment = 1 + +# [operator space] +space_around_math_operator = true + +space_after_comma = true + +space_after_comma_in_for_statement = true + +space_around_concat_operator = true + +# [align] + +align_call_args = false + +align_function_params = true + +align_continuous_assign_statement = true + +align_continuous_rect_table_field = true + +align_if_branch = true + +align_array_table = true + +# [indent] + +never_indent_before_if_condition = false + +never_indent_comment_on_if_branch = false + +# [line space] + +# The following configuration supports four expressions +# keep +# fixed(n) +# min(n) +# max(n) +# for eg. min(2) + +line_space_after_if_statement = keep + +line_space_after_do_statement = keep + +line_space_after_while_statement = keep + +line_space_after_repeat_statement = keep + +line_space_after_for_statement = keep + +line_space_after_local_or_assign_statement = keep + +line_space_after_function_statement = fixed(2) + +line_space_after_expression_statement = keep + +line_space_after_comment = keep + +# [line break] +break_all_list_when_line_exceed = false + +auto_collapse_lines = false + +# [preference] +ignore_space_after_colon = true + +remove_call_expression_list_finish_comma = false diff --git a/README.md b/README.md index 76d67d0..9c60057 100644 --- a/README.md +++ b/README.md @@ -158,13 +158,16 @@ The upper part of the popup contains the title, which can also be edited and sen ### Reviewing Diffs -The `review` action will open a diff of the changes. You can leave comments using the `create_comment` action. +The `review` action will open a diff of the changes. You can leave comments using the `create_comment` action or for multiline comments use `create_multiline_comment` in visual mode. ```lua require("gitlab").review() require("gitlab").create_comment() +require("gitlab").create_multiline_comment() ``` +For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with gitlab [suggest changes](https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html) code block with prefilled code from visual selection. + The reviewer is Delta by default, but you can configure the plugin to use Diffview instead. ### Discussions and Notes @@ -246,6 +249,8 @@ vim.keymap.set("n", "gls", gitlab.summary) vim.keymap.set("n", "glA", gitlab.approve) vim.keymap.set("n", "glR", gitlab.revoke) vim.keymap.set("n", "glc", gitlab.create_comment) +vim.keymap.set("v", "glc", gitlab.create_multiline_comment) +vim.keymap.set("v", "glC", gitlab.create_comment_suggestion) vim.keymap.set("n", "gln", gitlab.create_note) vim.keymap.set("n", "gld", gitlab.toggle_discussions) vim.keymap.set("n", "glaa", gitlab.add_assignee) diff --git a/cmd/comment.go b/cmd/comment.go index 1908e7b..0c2188f 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -1,8 +1,10 @@ package main import ( + "crypto/sha1" "encoding/json" "errors" + "fmt" "io" "net/http" @@ -12,14 +14,29 @@ import ( const mrVersionsUrl = "%s/api/v4/projects/%s/merge_requests/%d/versions" type PostCommentRequest struct { - Comment string `json:"comment"` - FileName string `json:"file_name"` - NewLine int `json:"new_line"` - OldLine int `json:"old_line"` - HeadCommitSHA string `json:"head_commit_sha"` - BaseCommitSHA string `json:"base_commit_sha"` - StartCommitSHA string `json:"start_commit_sha"` - Type string `json:"type"` + Comment string `json:"comment"` + FileName string `json:"file_name"` + NewLine *int `json:"new_line,omitempty"` + OldLine *int `json:"old_line,omitempty"` + HeadCommitSHA string `json:"head_commit_sha"` + BaseCommitSHA string `json:"base_commit_sha"` + StartCommitSHA string `json:"start_commit_sha"` + Type string `json:"type"` + LineRange *LineRange `json:"line_range,omitempty"` +} + +// LineRange represents the range of a note. +type LineRange struct { + StartRange *LinePosition `json:"start"` + EndRange *LinePosition `json:"end"` +} + +// LinePosition represents a position in a line range. +// unlike gitlab struct this does not contain LineCode with sha1 of filename +type LinePosition struct { + Type string `json:"type"` + OldLine int `json:"old_line"` + NewLine int `json:"new_line"` } type DeleteCommentRequest struct { @@ -115,16 +132,42 @@ func PostComment(w http.ResponseWriter, r *http.Request) { /* If we are leaving a comment on a line, leave position. Otherwise, we are leaving a note (unlinked comment) */ if postCommentRequest.FileName != "" { - opt.Position = &gitlab.NotePosition{ - PositionType: "text", - StartSHA: postCommentRequest.StartCommitSHA, - HeadSHA: postCommentRequest.HeadCommitSHA, - BaseSHA: postCommentRequest.BaseCommitSHA, - NewPath: postCommentRequest.FileName, - OldPath: postCommentRequest.FileName, + opt.Position = &gitlab.PositionOptions{ + PositionType: &postCommentRequest.Type, + StartSHA: &postCommentRequest.StartCommitSHA, + HeadSHA: &postCommentRequest.HeadCommitSHA, + BaseSHA: &postCommentRequest.BaseCommitSHA, + NewPath: &postCommentRequest.FileName, + OldPath: &postCommentRequest.FileName, NewLine: postCommentRequest.NewLine, OldLine: postCommentRequest.OldLine, } + + if postCommentRequest.LineRange != nil { + var format = "%x_%d_%d" + var start_filename_sha1 = fmt.Sprintf( + format, + sha1.Sum([]byte(postCommentRequest.FileName)), + postCommentRequest.LineRange.StartRange.OldLine, + postCommentRequest.LineRange.StartRange.NewLine, + ) + var end_filename_sha1 = fmt.Sprintf( + format, + sha1.Sum([]byte(postCommentRequest.FileName)), + postCommentRequest.LineRange.EndRange.OldLine, + postCommentRequest.LineRange.EndRange.NewLine, + ) + opt.Position.LineRange = &gitlab.LineRangeOptions{ + Start: &gitlab.LinePositionOptions{ + Type: &postCommentRequest.LineRange.StartRange.Type, + LineCode: &start_filename_sha1, + }, + End: &gitlab.LinePositionOptions{ + Type: &postCommentRequest.LineRange.EndRange.Type, + LineCode: &end_filename_sha1, + }, + } + } } discussion, _, err := c.git.Discussions.CreateMergeRequestDiscussion(c.projectId, c.mergeId, &opt) diff --git a/go.mod b/go.mod index 1305ae8..57f8fe2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module gitlab.com/harrisoncramer/gitlab.nvim go 1.19 -require github.com/xanzy/go-gitlab v0.83.0 +require github.com/xanzy/go-gitlab v0.93.2 require ( github.com/golang/protobuf v1.5.3 // indirect diff --git a/go.sum b/go.sum index bc150bb..1bd7d24 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw= github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= +github.com/xanzy/go-gitlab v0.93.2 h1:kNNf3BYNYn/Zkig0B89fma12l36VLcYSGu7OnaRlRDg= +github.com/xanzy/go-gitlab v0.93.2/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 660e5f6..2254926 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -1,35 +1,126 @@ -- This module is responsible for creating new comments -- in the reviewer's buffer. The reviewer will pass back -- to this module the data required to make the API calls -local Popup = require("nui.popup") -local state = require("gitlab.state") -local job = require("gitlab.job") -local u = require("gitlab.utils") -local discussions = require("gitlab.actions.discussions") -local miscellaneous = require("gitlab.actions.miscellaneous") -local reviewer = require("gitlab.reviewer") -local M = {} +local Popup = require("nui.popup") +local state = require("gitlab.state") +local job = require("gitlab.job") +local u = require("gitlab.utils") +local discussions = require("gitlab.actions.discussions") +local miscellaneous = require("gitlab.actions.miscellaneous") +local reviewer = require("gitlab.reviewer") +local M = {} -local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%")) -local note_popup = Popup(u.create_popup_state("Note", "40%", "60%")) +local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%")) +local note_popup = Popup(u.create_popup_state("Note", "40%", "60%")) --- This function will open a comment popup in order to create a comment on the changed/updated line in the current MR -M.create_comment = function() +-- This function will open a comment popup in order to create a comment on the changed/updated +-- line in the current MR +M.create_comment = function() comment_popup:mount() state.set_popup_keymaps(comment_popup, function(text) M.confirm_create_comment(text) end, miscellaneous.attach_file) end -M.create_note = function() - note_popup:mount() - state.set_popup_keymaps(note_popup, function(text) - M.confirm_create_comment(text, true) +---Create multiline comment for the last selection. +M.create_multiline_comment = function() + if not u.check_visual_mode() then + return + end + local start_line, end_line = u.get_visual_selection_boundaries() + comment_popup:mount() + state.set_popup_keymaps(comment_popup, function(text) + M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) end, miscellaneous.attach_file) end --- This function (settings.popup.perform_action) will send the comment to the Go server -M.confirm_create_comment = function(text, unlinked) +---Create comment prepopulated with gitlab suggestion +---https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html +M.create_comment_suggestion = function() + if not u.check_visual_mode() then + return + end + local start_line, end_line = u.get_visual_selection_boundaries() + local current_line = vim.api.nvim_win_get_cursor(0)[1] + local range = end_line - start_line + local backticks = "```" + local selected_lines = reviewer.get_lines(start_line, end_line) + + if selected_lines == nil then + -- TODO: remove when delta is supported + return + end + + for line in ipairs(selected_lines) do + if string.match(line, "^```$") then + backticks = "````" + break + end + end + + local suggestion_start + if start_line == current_line then + suggestion_start = backticks .. "suggestion:-0+" .. range + elseif end_line == current_line then + suggestion_start = backticks .. "suggestion:-" .. range .. "+0" + else + -- This should never happen afaik + vim.notify("Unexpected suggestion position", vim.log.levels.ERROR) + return + end + suggestion_start = suggestion_start + local suggestion_lines = {} + table.insert(suggestion_lines, suggestion_start) + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + + comment_popup:mount() + vim.api.nvim_buf_set_lines(comment_popup.bufnr, 0, 0, false, suggestion_lines) + state.set_popup_keymaps(comment_popup, function(text) + if range > 0 then + M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) + else + M.confirm_create_comment(text, nil) + end + end, miscellaneous.attach_file) +end + +M.create_note = function() + note_popup:mount() + state.set_popup_keymaps(note_popup, function(text) + M.confirm_create_comment(text, nil, true) + end, miscellaneous.attach_file) +end + +---@class LineRange +---@field start_line integer +---@field end_line integer + +---@class ReviewerLineInfo +---@field old_line integer +---@field new_line integer +---@field type string either "new" or "old" + +---@class ReviewerRangeInfo +---@field start ReviewerLineInfo +---@field end ReviewerLineInfo + +---@class ReviewerInfo +---@field file_name string +---@field old_line integer | nil +---@field new_line integer | nil +---@field range_info ReviewerRangeInfo + +---This function (settings.popup.perform_action) will send the comment to the Go server +---@param text string comment text +---@param range LineRange | nil range of visuel selection or nil +---@param unlinked boolean | nil if true, the comment is not linked to a line +M.confirm_create_comment = function(text, range, unlinked) + if text == nil then + vim.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) + return + end + if unlinked then local body = { comment = text } job.run_job("/comment", "POST", body, function(data) @@ -39,38 +130,22 @@ M.confirm_create_comment = function(text, unlinked) return end - local file_name, line_numbers, error = reviewer.get_location() - - if error then - vim.notify(error, vim.log.levels.ERROR) - return - end - - if file_name == nil then - vim.notify("Reviewer did not provide file name", vim.log.levels.ERROR) - return - end - - if line_numbers == nil then - vim.notify("Reviewer did not provide line numbers of change", vim.log.levels.ERROR) - return - end - - if text == nil then - vim.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) + local reviewer_info = reviewer.get_location(range) + if not reviewer_info then return end local revision = state.MR_REVISIONS[1] local body = { comment = text, - file_name = file_name, - old_line = line_numbers.old_line, - new_line = line_numbers.new_line, + file_name = reviewer_info.file_name, + old_line = reviewer_info.old_line, + new_line = reviewer_info.new_line, base_commit_sha = revision.base_commit_sha, start_commit_sha = revision.start_commit_sha, head_commit_sha = revision.head_commit_sha, - type = "modification" + type = "text", + line_range = reviewer_info.range_info, } job.run_job("/comment", "POST", body, function(data) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 39d1b83..39c89e4 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -25,25 +25,27 @@ return { u.has_reviewer(args.reviewer or "delta") end, -- Global Actions 🌎 - summary = async.sequence({ info }, summary.summary), - approve = async.sequence({ info }, approvals.approve), - revoke = async.sequence({ info }, approvals.revoke), - add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), - delete_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.delete_reviewer), - add_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.add_assignee), - delete_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.delete_assignee), - create_comment = async.sequence({ info, revisions }, comment.create_comment), - create_note = async.sequence({ info }, comment.create_note), - review = async.sequence({ u.merge(info, { refresh = true }) }, function() reviewer.open() end), - pipeline = async.sequence({ info }, pipeline.open), + summary = async.sequence({ info }, summary.summary), + approve = async.sequence({ info }, approvals.approve), + revoke = async.sequence({ info }, approvals.revoke), + add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), + delete_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.delete_reviewer), + add_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.add_assignee), + delete_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.delete_assignee), + create_comment = async.sequence({ info, revisions }, comment.create_comment), + create_multiline_comment = async.sequence({ info, revisions }, comment.create_multiline_comment), + create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), + create_note = async.sequence({ info }, comment.create_note), + review = async.sequence({ u.merge(info, { refresh = true }) }, function() reviewer.open() end), + pipeline = async.sequence({ info }, pipeline.open), -- Discussion Tree Actions 🌴 - toggle_discussions = async.sequence({ info }, discussions.toggle), - edit_comment = async.sequence({ info }, discussions.edit_comment), - delete_comment = async.sequence({ info }, discussions.delete_comment), - toggle_resolved = async.sequence({ info }, discussions.toggle_resolved), - reply = async.sequence({ info }, discussions.reply), + toggle_discussions = async.sequence({ info }, discussions.toggle), + edit_comment = async.sequence({ info }, discussions.edit_comment), + delete_comment = async.sequence({ info }, discussions.delete_comment), + toggle_resolved = async.sequence({ info }, discussions.toggle_resolved), + reply = async.sequence({ info }, discussions.reply), -- Other functions 🤷 - state = state, - print_settings = state.print_settings, - open_in_browser = async.sequence({ info }, miscellaneous.open_in_browser), + state = state, + print_settings = state.print_settings, + open_in_browser = async.sequence({ info }, miscellaneous.open_in_browser), } diff --git a/lua/gitlab/reviewer/delta.lua b/lua/gitlab/reviewer/delta.lua index 2295acb..64f82c6 100644 --- a/lua/gitlab/reviewer/delta.lua +++ b/lua/gitlab/reviewer/delta.lua @@ -1,16 +1,16 @@ -- This Module contains all of the code specific to the Delta reviewer. -local state = require("gitlab.state") -local u = require("gitlab.utils") +local state = require("gitlab.state") +local u = require("gitlab.utils") -local M = { - bufnr = nil +local M = { + bufnr = nil, } -- Public Functions -- These functions are exposed externally and are used -- when the reviewer is consumed by other code. They must follow the specification -- outlined in the reviewer/init.lua file -M.open = function() +M.open = function() local current_buf = vim.api.nvim_get_current_buf() if current_buf == state.discussion_buf then vim.api.nvim_command("wincmd w") @@ -23,20 +23,22 @@ M.open = function() end local term_command_template = - "GIT_PAGER='delta --hunk-header-style omit --line-numbers --paging never --file-added-label %s --file-removed-label %s --file-modified-label %s' git diff %s...HEAD" + "GIT_PAGER='delta --hunk-header-style omit --line-numbers --paging never --file-added-label %s --file-removed-label %s --file-modified-label %s' git diff %s...HEAD" - local term_command = string.format(term_command_template, + local term_command = string.format( + term_command_template, state.settings.review_pane.delta.added_file, state.settings.review_pane.delta.removed_file, state.settings.review_pane.delta.modified_file, - state.INFO.target_branch) + state.INFO.target_branch + ) vim.fn.termopen(term_command) -- Calls delta and sends the output to the currently blank buffer M.bufnr = vim.api.nvim_get_current_buf() M.winnr = vim.api.nvim_get_current_win() end -M.jump = function(file_name, new_line, old_line) +M.jump = function(file_name, new_line, old_line) local linnr, error = M.get_jump_location(file_name, new_line, old_line) if error ~= nil then vim.notify(error, vim.log.levels.ERROR) @@ -47,25 +49,47 @@ M.jump = function(file_name, new_line, old_line) u.jump_to_buffer(M.bufnr, linnr) end -M.get_location = function() - if M.bufnr == nil then return nil, nil, "Delta reviewer must be initialized first" end +---Get the location of a line within the delta buffer. If range is specified, then also the location +---of the lines in range. +---@param range LineRange | nil Line range to get location for +---@return ReviewerInfo | nil nil is returned only if error was encountered +M.get_location = function(range) + if M.bufnr == nil then + vim.notify("Delta reviewer must be initialized first", vim.log.levels.ERROR) + return + end + + if range then + vim.notify("Multiline comments are not yet supported for delta reviewer", vim.log.levels.ERROR) + return + end local bufnr = vim.api.nvim_get_current_buf() - if bufnr ~= M.bufnr then return nil, nil, "Line location can only be determined within reviewer window" end + if bufnr ~= M.bufnr then + vim.notify("Line location can only be determined within reviewer window") + return + end local line_num = u.get_current_line_number() local file_name = M.get_file_from_review_buffer(u.get_current_line_number()) - local range, error = M.get_review_buffer_range(file_name) + local review_range, error = M.get_review_buffer_range(file_name) - if error ~= nil then return nil, nil, error end - if range == nil then return nil, nil, "Review buffer range could not be identified" end + if error ~= nil then + vim.notify(error, vim.log.levels.ERROR) + return + end + + if review_range == nil then + vim.notify("Review buffer range could not be identified", vim.log.levels.ERROR) + return + end -- In case the comment is left on a line without change information, we -- iterate backward until we find it within the range of the changes local current_line_changes = nil local num = line_num - while range ~= nil and num >= range[1] and current_line_changes == nil do + while review_range ~= nil and num >= review_range[1] and current_line_changes == nil do local content = u.get_line_content(M.bufnr, num) local change_nums = M.get_change_nums(content) current_line_changes = change_nums @@ -73,12 +97,13 @@ M.get_location = function() end if current_line_changes == nil then - return nil, nil, "Could not find current line change information" + vim.notify("Could not find current line change information", vim.log.levels.ERROR) + return end local new_line_num = line_num + 1 local next_line_changes = nil - while range ~= nil and new_line_num <= range[2] and next_line_changes == nil do + while review_range ~= nil and new_line_num <= review_range[2] and next_line_changes == nil do local content = u.get_line_content(M.bufnr, new_line_num) local change_nums = M.get_change_nums(content) next_line_changes = change_nums @@ -86,41 +111,53 @@ M.get_location = function() end if next_line_changes == nil then - return nil, nil, "Could not find next line change information" + vim.notify("Could not find next line change information", vim.log.levels.ERROR) + return end + local result = { file_name = file_name } -- This is actually a modified line if these conditions are met - if (current_line_changes.old_line and not current_line_changes.new_line and not next_line_changes.old_line and next_line_changes.new_line) then - do - current_line_changes = { - old_line = current_line_changes.old, - new_line = next_line_changes.new_line - } - end + if + current_line_changes.old_line + and not current_line_changes.new_line + and not next_line_changes.old_line + and next_line_changes.new_line + then + result.old_line = current_line_changes.old + result.new_line = next_line_changes.new_line + else + vim.notify("Could not determine line location", vim.log.levels.ERROR) + return end - return file_name, current_line_changes + return result end -- Helper Functions 🤝 -- These functions are not exported and should be private -- to the delta reviewer, they are used to support the public functions -M.get_jump_location = function(file_name, new_line, old_line) +M.get_jump_location = function(file_name, new_line, old_line) local range, error = M.get_review_buffer_range(file_name) - if error ~= nil then return nil, error end - if range == nil then return nil, "Review buffer range could not be identified" end + if error ~= nil then + return nil, error + end + if range == nil then + return nil, "Review buffer range could not be identified" + end local linnr = nil local lines = M.get_review_buffer_lines(range) for _, line in ipairs(lines) do local line_data = M.get_change_nums(line.line_content) - if old_line == line_data.old_line and new_line == line_data.new_line then + if line_data and old_line == line_data.old_line and new_line == line_data.new_line then linnr = line.line_number break end end - if linnr == nil then return nil, "Could not find matching line" end + if linnr == nil then + return nil, "Could not find matching line" + end return linnr, nil end @@ -134,32 +171,39 @@ M.get_file_from_review_buffer = function(linenr) end end -M.get_change_nums = function(line) +M.get_change_nums = function(line) local data, _ = line:match("(.-)" .. "│" .. "(.*)") local line_data = {} - if data == nil then return nil end + if data == nil then + return nil + end - if data ~= nil then + if data ~= nil and data ~= "" then local old_line = u.trim(u.get_first_chunk(data, "[^" .. "⋮" .. "]+")) local new_line = u.trim(u.get_last_chunk(data, "[^" .. "⋮" .. "]+")) line_data.new_line = tonumber(new_line) line_data.old_line = tonumber(old_line) end - if line_data.new_line == nil and line_data.old_line == nil then return nil end + if line_data.new_line == nil and line_data.old_line == nil then + return nil + end return line_data end - M.get_review_buffer_range = function(file_name) - if M.bufnr == nil then return nil, "Delta reviewer must be initialized first" end + if M.bufnr == nil then + return nil, "Delta reviewer must be initialized first" + end local lines = vim.api.nvim_buf_get_lines(M.bufnr, 0, -1, false) local start = nil local stop = nil for i, line in ipairs(lines) do - if start ~= nil and stop ~= nil then return { start, stop } end + if start ~= nil and stop ~= nil then + return { start, stop } + end if M.starts_with_file_symbol(line) then -- Check if the file name matches the node name local delta_file_name = u.get_last_chunk(line) @@ -173,7 +217,9 @@ M.get_review_buffer_range = function(file_name) -- We've reached the end of the file, set "stop" in case we already found start stop = #lines - if start ~= nil and stop ~= nil then return { start, stop } end + if start ~= nil and stop ~= nil then + return { start, stop } + end end M.starts_with_file_symbol = function(line) @@ -200,4 +246,12 @@ M.get_review_buffer_lines = function(review_buffer_range) return lines end +---Return content between start_line and end_line +---@param start_line integer +---@param end_line integer +---@return string[] | nil +M.get_lines = function(start_line, end_line) + vim.notify("Getting lines in delta is not supported yet", vim.log.levels.ERROR) + return nil +end return M diff --git a/lua/gitlab/reviewer/diffview.lua b/lua/gitlab/reviewer/diffview.lua index c50b3e3..f53d0ee 100644 --- a/lua/gitlab/reviewer/diffview.lua +++ b/lua/gitlab/reviewer/diffview.lua @@ -1,22 +1,23 @@ -- This Module contains all of the code specific to the Diffview reviewer. -local state = require("gitlab.state") +local u = require("gitlab.utils") +local state = require("gitlab.state") local async_ok, async = pcall(require, "diffview.async") -local M = { +local M = { bufnr = nil, - tabnr = nil + tabnr = nil, } -- Public Functions -- These functions are exposed externally and are used -- when the reviewer is consumed by other code. They must follow the specification -- outlined in the reviewer/init.lua file -M.open = function() +M.open = function() vim.api.nvim_command(string.format("DiffviewOpen %s", state.INFO.target_branch)) M.tabnr = vim.api.nvim_get_current_tabpage() end -M.jump = function(file_name, new_line, old_line) +M.jump = function(file_name, new_line, old_line) if M.tabnr == nil then vim.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return @@ -49,12 +50,25 @@ M.jump = function(file_name, new_line, old_line) end end -M.get_location = function() - if M.tabnr == nil then return nil, nil, "Diffview reviewer must be initialized first" end +---Get the location of a line within the diffview. If range is specified, then also the location +---of the lines in range. +---@param range LineRange | nil Line range to get location for +---@return ReviewerInfo | nil nil is returned only if error was encountered +M.get_location = function(range) + if M.tabnr == nil then + vim.notify("Diffview reviewer must be initialized first") + return + end local bufnr = vim.api.nvim_get_current_buf() + local current_line = vim.api.nvim_win_get_cursor(0)[1] + -- check if we are in the diffview tab local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= M.tabnr then return nil, nil, "Line location can only be determined within reviewer window" end + if tabnr ~= M.tabnr then + vim.notify("Line location can only be determined within reviewer window") + return + end + -- check if we are in the diffview buffer local view = require("diffview.lib").get_current_view() if view == nil then @@ -62,18 +76,81 @@ M.get_location = function() return end local layout = view.cur_layout - local file_name = nil - local current_line_changes = nil + local result = {} + local type + local is_new if layout.a.file.bufnr == bufnr then - file_name = layout.a.file.path - current_line_changes = { new_line = nil, old_line = vim.api.nvim_win_get_cursor(0)[1] } - return file_name, current_line_changes + result.file_name = layout.a.file.path + result.old_line = current_line + type = "old" + is_new = false elseif layout.b.file.bufnr == bufnr then - file_name = layout.b.file.path - current_line_changes = { new_line = vim.api.nvim_win_get_cursor(0)[1], old_line = nil } - return file_name, current_line_changes + result.file_name = layout.b.file.path + result.new_line = current_line + type = "new" + is_new = true + else + vim.notify("Line location can only be determined within reviewer window") + return end - return nil, nil, "Line location can only be determined within reviewer window" + + local hunks = u.parse_hunk_headers(result.file_name, state.INFO.target_branch) + if hunks == nil then + vim.notify("Could not parse hunks", vim.log.levels.ERROR) + return + end + + local current_line_info + if is_new then + current_line_info = u.get_lines_from_hunks(hunks, result.new_line, is_new) + else + current_line_info = u.get_lines_from_hunks(hunks, result.old_line, is_new) + end + + -- If single line comment is outside of changed lines then we need to specify both new line and old line + -- otherwise the API returns error. + -- https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff + if not current_line_info.in_hunk then + result.old_line = current_line_info.old_line + result.new_line = current_line_info.new_line + end + + if range == nil then + return result + end + + result.range_info = { start = {}, ["end"] = {} } + if current_line == range.start_line then + result.range_info.start.old_line = current_line_info.old_line + result.range_info.start.new_line = current_line_info.new_line + result.range_info.start.type = type + else + local start_line_info = u.get_lines_from_hunks(hunks, range.start_line, is_new) + result.range_info.start.old_line = start_line_info.old_line + result.range_info.start.new_line = start_line_info.new_line + result.range_info.start.type = type + end + + if current_line == range.end_line then + result.range_info["end"].old_line = current_line_info.old_line + result.range_info["end"].new_line = current_line_info.new_line + result.range_info["end"].type = type + else + local end_line_info = u.get_lines_from_hunks(hunks, range.end_line, is_new) + result.range_info["end"].old_line = end_line_info.old_line + result.range_info["end"].new_line = end_line_info.new_line + result.range_info["end"].type = type + end + + return result +end + +---Return content between start_line and end_line +---@param start_line integer +---@param end_line integer +---@return string[] +M.get_lines = function(start_line, end_line) + return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) end return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index f4c0932..1cced8d 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -10,7 +10,7 @@ local M = { local reviewer_map = { delta = delta, - diffview = diffview + diffview = diffview, } M.init = function() @@ -31,9 +31,12 @@ M.init = function() -- • {interval} The old_line of the change M.get_location = reviewer.get_location - -- Returns the current location (based on cursor) from the reviewer window in format: - -- file_name, {new_line, old_line}, error + -- Parameters: + -- • {range} LineRange if function was triggered from visual selection + -- Returns the current location (based on cursor) from the reviewer window as ReviewerInfo class + + M.get_lines = reviewer.get_lines + -- Returns the content of the file in the current location in the reviewer window end - return M diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 5f36d87..4e68bde 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -1,7 +1,8 @@ +local Job = require("plenary.job") local M = {} M.get_current_line_number = function() - return vim.api.nvim_call_function('line', { '.' }) + return vim.api.nvim_call_function("line", { "." }) end M.has_reviewer = function(reviewer) @@ -62,13 +63,13 @@ M.format_date = function(date_string) day = date_table.day, hour = date_table.hour, min = date_table.min, - sec = date_table.sec + sec = date_table.sec, }) local time_diff = current_date - date local function pluralize(num, word) - return num .. string.format(" %s", word) .. (num > 1 and "s" or '') .. " ago" + return num .. string.format(" %s", word) .. (num > 1 and "s" or "") .. " ago" end if time_diff < 60 then @@ -86,7 +87,9 @@ M.format_date = function(date_string) end M.jump_to_file = function(filename, line_number) - if line_number == nil then line_number = 1 end + if line_number == nil then + line_number = 1 + end local bufnr = vim.fn.bufnr(filename) if bufnr ~= -1 then M.jump_to_buffer(bufnr, line_number) @@ -106,7 +109,7 @@ end M.create_popup_state = function(title, width, height) return { buf_options = { - filetype = 'markdown' + filetype = "markdown", }, relative = "editor", enter = true, @@ -114,7 +117,7 @@ M.create_popup_state = function(title, width, height) border = { style = "rounded", text = { - top = title + top = title, }, }, position = "50%", @@ -142,7 +145,7 @@ M.join = function(tbl, separator) -- Remove the trailing separator if separator ~= "" then - result = result:sub(1, - #separator - 1) + result = result:sub(1, -#separator - 1) end return result @@ -169,16 +172,16 @@ M.read_file = function(file_path) end M.current_file_path = function() - local path = debug.getinfo(1, 'S').source:sub(2) - return vim.fn.fnamemodify(path, ':p') + local path = debug.getinfo(1, "S").source:sub(2) + return vim.fn.fnamemodify(path, ":p") end local random = math.random M.uuid = function() - local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' - return string.gsub(template, '[xy]', function(c) - local v = (c == 'x') and random(0, 0xf) or random(8, 0xb) - return string.format('%x', v) + local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" + return string.gsub(template, "[xy]", function(c) + local v = (c == "x") and random(0, 0xf) or random(8, 0xb) + return string.format("%x", v) end) end @@ -192,7 +195,9 @@ end M.table_size = function(t) local count = 0 - for _ in pairs(t) do count = count + 1 end + for _ in pairs(t) do + count = count + 1 + end return count end @@ -247,11 +252,7 @@ end M.get_line_content = function(bufnr, start) local current_buffer = vim.api.nvim_get_current_buf() - local lines = vim.api.nvim_buf_get_lines( - bufnr ~= nil and bufnr or current_buffer, - start - 1, - start, - false) + local lines = vim.api.nvim_buf_get_lines(bufnr ~= nil and bufnr or current_buffer, start - 1, start, false) for _, line in ipairs(lines) do return line @@ -267,8 +268,8 @@ M.get_win_from_buf = function(bufnr) end M.switch_can_edit_buf = function(buf, bool) - vim.api.nvim_buf_set_option(buf, 'modifiable', bool) - vim.api.nvim_buf_set_option(buf, "readonly", not bool) + vim.api.nvim_set_option_value("modifiable", bool, { buf = buf }) + vim.api.nvim_set_option_value("readonly", not bool, { buf = buf }) end M.list_files_in_folder = function(folder_path) @@ -278,12 +279,14 @@ M.list_files_in_folder = function(folder_path) local folder_ok, folder = pcall(vim.fn.readdir, folder_path) - if not folder_ok then return nil end + if not folder_ok then + return nil + end local files = {} if folder ~= nil then for _, file in ipairs(folder) do - local file_path = folder_path .. (M.is_windows() and "\\" or '/') .. file + local file_path = folder_path .. (M.is_windows() and "\\" or "/") .. file local timestamp = vim.fn.getftime(file_path) table.insert(files, { name = file, timestamp = timestamp }) end @@ -310,4 +313,149 @@ M.reverse = function(list) return rev end +---@class Hunk +---@field old_line integer +---@field old_range integer +---@field new_line integer +---@field new_range integer + +---Parse git diff hunks. +---@param file_path string Path to file. +---@param base_branch string Git base branch of merge request. +---@return Hunk[] list of hunks. +M.parse_hunk_headers = function(file_path, base_branch) + local hunks = {} + + local diff_job = Job:new({ + command = "git", + args = { "diff", "--minimal", "--unified=0", "--no-color", base_branch, "--", file_path }, + on_exit = function(j, return_code) + if return_code == 0 then + for _, line in ipairs(j:result()) do + if line:sub(1, 2) == "@@" then + -- match: + -- @@ -23 +23 @@ ... + -- @@ -23,0 +23 @@ ... + -- @@ -41,0 +42,4 @@ ... + local old_start, old_range, new_start, new_range = line:match("@@+ %-(%d+),?(%d*) %+(%d+),?(%d*) @@+") + + table.insert(hunks, { + old_line = tonumber(old_start), + old_range = tonumber(old_range) or 0, + new_line = tonumber(new_start), + new_range = tonumber(new_range) or 0, + }) + end + end + else + vim.notify("Failed to get git diff: " .. j:stderr(), vim.log.levels.WARN) + end + end, + }) + + diff_job:sync() + + return hunks +end + +---@class LineDiffInfo +---@field old_line integer +---@field new_line integer +---@field in_hunk boolean + +---Search git diff hunks to find old and new line number corresponding to target line. +---This function does not check if target line is outside of boundaries of file. +---@param hunks Hunk[] git diff parsed hunks. +---@param target_line integer line number to search for - based on is_new paramter the search is +---either in new lines or old lines of hunks. +---@param is_new boolean whether to search for new line or old line +---@return LineDiffInfo +M.get_lines_from_hunks = function(hunks, target_line, is_new) + if #hunks == 0 then + -- If there are zero hunks, return target_line for both old and new lines + return { old_line = target_line, new_line = target_line, in_hunk = false } + end + local current_new_line = 0 + local current_old_line = 0 + if is_new then + for _, hunk in ipairs(hunks) do + -- target line is before current hunk + if target_line < hunk.new_line then + return { + old_line = current_old_line + (target_line - current_new_line), + new_line = target_line, + in_hunk = false, + } + -- target line is within the current hunk + elseif hunk.new_line <= target_line and target_line <= (hunk.new_line + hunk.new_range) then + -- this is interesting magic of gitlab calculation + return { + old_line = hunk.old_line + hunk.old_range + 1, + new_line = target_line, + in_hunk = true, + } + -- target line is after the current hunk + else + current_new_line = hunk.new_line + hunk.new_range + current_old_line = hunk.old_line + hunk.old_range + end + end + -- target line is after last hunk + return { + old_line = current_old_line + (target_line - current_new_line), + new_line = target_line, + in_hunk = false, + } + else + for _, hunk in ipairs(hunks) do + -- target line is before current hunk + if target_line < hunk.old_line then + return { + old_line = target_line, + new_line = current_new_line + (target_line - current_old_line), + in_hunk = false, + } + -- target line is within the current hunk + elseif hunk.old_line <= target_line and target_line <= (hunk.old_line + hunk.old_range) then + return { + old_line = target_line, + new_line = hunk.new_line, + in_hunk = true, + } + -- target line is after the current hunk + else + current_new_line = hunk.new_line + hunk.new_range + current_old_line = hunk.old_line + hunk.old_range + end + end + -- target line is after last hunk + return { + old_line = current_old_line + (target_line - current_new_line), + new_line = target_line, + in_hunk = false, + } + end +end + +---Check if current mode is visual mode +---@return boolean true if current mode is visual mode +M.check_visual_mode = function() + local mode = vim.api.nvim_get_mode().mode + if mode ~= "v" and mode ~= "V" then + vim.notify("Code suggestions are only available in visual mode", vim.log.levels.WARN) + return false + end + return true +end + +---Return start line and end line of visual selection. +---Exists visual mode in order to access marks "<" , ">" +---@return integer,integer start line and end line +M.get_visual_selection_boundaries = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "nx", false) + local start_line = vim.api.nvim_buf_get_mark(0, "<")[1] + local end_line = vim.api.nvim_buf_get_mark(0, ">")[1] + return start_line, end_line +end + return M