--- 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 Layout = require("nui.layout") local state = require("gitlab.state") local job = require("gitlab.job") local u = require("gitlab.utils") local popup = require("gitlab.popup") local git = require("gitlab.git") local discussions = require("gitlab.actions.discussions") local draft_notes = require("gitlab.actions.draft_notes") local miscellaneous = require("gitlab.actions.miscellaneous") local reviewer = require("gitlab.reviewer") local Location = require("gitlab.reviewer.location") local M = { start_line = nil, end_line = nil, draft_popup = nil, comment_popup = nil, } ---Fires the API that sends the comment data to the Go server, called when you "confirm" creation ---via the M.settings.keymaps.popup.perform_action keybinding ---@param text string comment text ---@param unlinked boolean if true, the comment is not linked to a line ---@param discussion_id string | nil The ID of the discussion to which the reply is responding, nil if not a reply local confirm_create_comment = function(text, unlinked, discussion_id) if text == nil then u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) return end local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) -- Creating a normal reply to a discussion if discussion_id ~= nil and not is_draft then local body = { discussion_id = discussion_id, reply = text, draft = is_draft } job.run_job("/mr/reply", "POST", body, function() u.notify("Sent reply!", vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) return end -- Creating a draft reply, in response to a discussion ID if discussion_id ~= nil and is_draft then local body = { comment = text, discussion_id = discussion_id } job.run_job("/mr/draft_notes/", "POST", body, function() u.notify("Draft reply created!", vim.log.levels.INFO) draft_notes.load_draft_notes(function() discussions.rebuild_view(unlinked) end) end) return end -- Creating a note (unlinked comment) if unlinked and discussion_id == nil then local body = { comment = text } local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" job.run_job(endpoint, "POST", body, function() u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO) if is_draft then draft_notes.load_draft_notes(function() discussions.rebuild_view(unlinked) end) else discussions.rebuild_view(unlinked) end end) return end local revision = state.MR_REVISIONS[1] local position_data = { file_name = M.location.reviewer_data.file_name, old_file_name = M.location.reviewer_data.old_file_name, base_commit_sha = revision.base_commit_sha, start_commit_sha = revision.start_commit_sha, head_commit_sha = revision.head_commit_sha, old_line = M.location.location_data.old_line, new_line = M.location.location_data.new_line, line_range = M.location.location_data.line_range, } -- Creating a new comment (linked to specific changes) local body = u.merge({ type = "text", comment = text }, position_data) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" job.run_job(endpoint, "POST", body, function() u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO) if is_draft then draft_notes.load_draft_notes(function() discussions.rebuild_view(unlinked) end) else discussions.rebuild_view(unlinked) end end) end -- This function will actually send the deletion to Gitlab when you make a selection, -- and re-render the tree ---@param note_id integer ---@param discussion_id string ---@param unlinked boolean M.confirm_delete_comment = function(note_id, discussion_id, unlinked) local body = { discussion_id = discussion_id, note_id = tonumber(note_id) } job.run_job("/mr/comment", "DELETE", body, function(data) u.notify(data.message, vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) end ---This function sends the edited comment to the Go server ---@param discussion_id string ---@param note_id integer ---@param unlinked boolean M.confirm_edit_comment = function(discussion_id, note_id, unlinked) return function(text) local body = { discussion_id = discussion_id, note_id = note_id, comment = text, } job.run_job("/mr/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) discussions.rebuild_view(unlinked) end) end end ---@class LayoutOpts ---@field unlinked boolean ---@field discussion_id string|nil ---@field reply boolean|nil ---@field file_name string|nil ---This function sets up the layout and popups needed to create a comment, note and ---multi-line comment. It also sets up the basic keybindings for switching between ---window panes, and for the non-primary sections. ---@param opts LayoutOpts ---@return NuiLayout M.create_comment_layout = function(opts) local popup_settings = state.settings.popup local title local user_settings if opts.discussion_id ~= nil then title = "Reply" .. (opts.file_name and string.format(" [%s]", opts.file_name) or "") user_settings = popup_settings.reply elseif opts.unlinked then title = "Note" user_settings = popup_settings.note else local file_name = (M.location.reviewer_data.new_sha_focused or M.location.reviewer_data.old_file_name == "") and M.location.reviewer_data.file_name or M.location.reviewer_data.old_file_name title = popup.create_title("Comment", file_name, M.location.visual_range.start_line, M.location.visual_range.end_line) user_settings = popup_settings.comment end local settings = u.merge(popup_settings, user_settings or {}) local current_win = vim.api.nvim_get_current_win() M.comment_popup = Popup(popup.create_popup_state(title, settings)) M.draft_popup = Popup(popup.create_box_popup_state("Draft", false, settings)) local internal_layout = Layout.Box({ Layout.Box(M.comment_popup, { grow = 1 }), Layout.Box(M.draft_popup, { size = 3 }), }, { dir = "col" }) local layout = Layout({ position = settings.position, relative = "editor", size = { width = settings.width, height = settings.height, }, }, internal_layout) popup.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup }) popup.set_up_autocommands(M.comment_popup, layout, current_win) local unlinked = opts.unlinked or false ---Keybinding for focus on draft section popup.set_popup_keymaps(M.draft_popup, function() local text = u.get_buffer_text(M.comment_popup.bufnr) confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.toggle_bool, popup.non_editable_popup_opts) ---Keybinding for focus on text section popup.set_popup_keymaps(M.comment_popup, function(text) confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.attach_file, popup.editable_popup_opts) vim.schedule(function() local draft_mode = state.settings.discussion_tree.draft_mode vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(draft_mode) }) end) return layout end --- 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() M.location = Location.new() if not M.can_create_comment(false) then return end local layout = M.create_comment_layout({ unlinked = false }) layout:mount() end --- This function will open a multi-line comment popup in order to create a multi-line comment --- on the changed/updated line in the current MR M.create_multiline_comment = function() M.location = Location.new() if not M.can_create_comment(true) then u.press_escape() return end local layout = M.create_comment_layout({ unlinked = false }) layout:mount() end --- This function will open a a popup to create a "note" (e.g. unlinked comment) --- on the changed/updated line in the current MR M.create_note = function() local layout = M.create_comment_layout({ unlinked = true }) layout:mount() end ---Given the current visually selected area of text, builds text to fill in the ---comment popup with a suggested change ---@return LineRange|nil local build_suggestion = function() local current_line = vim.api.nvim_win_get_cursor(0)[1] local range_length = M.location.visual_range.end_line - M.location.visual_range.start_line local backticks = "```" local selected_lines = u.get_lines(M.location.visual_range.start_line, M.location.visual_range.end_line) for _, line in ipairs(selected_lines) do if string.match(line, "^```%S*$") then backticks = "````" break end end local suggestion_start if M.location.visual_range.start_line == current_line then suggestion_start = backticks .. "suggestion:-0+" .. range_length elseif M.location.visual_range.end_line == current_line then suggestion_start = backticks .. "suggestion:-" .. range_length .. "+0" else --- This should never happen afaik u.notify("Unexpected suggestion position", vim.log.levels.ERROR) return nil 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) return suggestion_lines end --- This function will open a a popup to create a suggestion comment --- on the changed/updated line in the current MR --- See: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html M.create_comment_suggestion = function() M.location = Location.new() if not M.can_create_comment(true) then u.press_escape() return end local suggestion_lines = build_suggestion() local layout = M.create_comment_layout({ unlinked = false }) layout:mount() vim.schedule(function() if suggestion_lines then vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) end end) end ---Returns true if it's possible to create an Inline Comment ---@param must_be_visual boolean True if current mode must be visual ---@return boolean M.can_create_comment = function(must_be_visual) -- Check that diffview is initialized if reviewer.tabnr == nil then u.notify("Reviewer must be initialized first", vim.log.levels.ERROR) return false end -- Check that we are in the Diffview tab local tabnr = vim.api.nvim_get_current_tabpage() if tabnr ~= reviewer.tabnr then u.notify("Comments can only be left in the reviewer pane", vim.log.levels.ERROR) return false end -- Check that we are hovering over the code local filetype = vim.bo[0].filetype if filetype == "DiffviewFiles" or filetype == "gitlab" then u.notify( "Comments can only be left on the code. To leave unlinked comments, use gitlab.create_note() instead", vim.log.levels.ERROR ) return false end -- Check that the file has not been renamed if reviewer.is_file_renamed() and not reviewer.does_file_have_changes() then u.notify("Commenting on (unchanged) renamed or moved files is not supported", vim.log.levels.ERROR) return false end -- Check that we are in a valid buffer if not M.sha_exists() then return false end -- Check that there aren't saved modifications local file = reviewer.get_current_file_path() if file == nil then return false end local has_changes, err = git.has_changes(file) if err ~= nil then return false end -- Check that there aren't unsaved modifications local is_modified = vim.bo[0].modified if state.settings.reviewer_settings.diffview.imply_local and (is_modified or has_changes) then u.notify("Cannot leave comments on changed files, please stash or commit and push", vim.log.levels.ERROR) return false end -- Check we're in visual mode for code suggestions and multiline comments if must_be_visual and not u.check_visual_mode() then return false end if M.location == nil or M.location.location_data == nil then u.notify("Error getting location information", vim.log.levels.ERROR) return false end return true end ---Checks to see whether you are commenting on a valid buffer. The Diffview plugin names non-existent ---buffers as 'null' ---@return boolean M.sha_exists = function() if vim.fn.expand("%") == "diffview://null" then u.notify("This file does not exist, please comment on the other buffer", vim.log.levels.ERROR) return false end return true end return M