fix: format of date when MR was closed or merged (#367) refactor: Add Payload Validators + Middleware In Go Code (#366) fix: Add better checks for leaving comments (#369) fix: regex support for http credentials embedded in remote url (#372) fix: Comment on single line selects two lines (#371) This is a #PATCH release.
380 lines
12 KiB
Lua
380 lines
12 KiB
Lua
--- 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 diffview_lib = require("diffview.lib")
|
|
local state = require("gitlab.state")
|
|
local job = require("gitlab.job")
|
|
local u = require("gitlab.utils")
|
|
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 = {
|
|
current_win = nil,
|
|
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 visual_range LineRange | nil range of visual selection or nil
|
|
---@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, visual_range, 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)
|
|
if is_draft then
|
|
draft_notes.load_draft_notes(function()
|
|
discussions.rebuild_view(unlinked)
|
|
end)
|
|
else
|
|
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 reviewer_data = reviewer.get_reviewer_data()
|
|
if reviewer_data == nil then
|
|
u.notify("Error getting reviewer data", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
local location = Location.new(reviewer_data, visual_range)
|
|
location:build_location_data()
|
|
local location_data = location.location_data
|
|
if location_data == nil then
|
|
u.notify("Error getting location information", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
local revision = state.MR_REVISIONS[1]
|
|
local position_data = {
|
|
file_name = reviewer_data.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 = location_data.old_line,
|
|
new_line = location_data.new_line,
|
|
line_range = location_data.line_range,
|
|
}
|
|
|
|
-- 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, position = position_data }
|
|
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(false, true)
|
|
end)
|
|
end)
|
|
return
|
|
end
|
|
|
|
-- 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 ranged boolean
|
|
---@field unlinked boolean
|
|
---@field discussion_id 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|nil
|
|
M.create_comment_layout = function(opts)
|
|
if opts.unlinked ~= true then
|
|
-- Check that diffview is initialized
|
|
if reviewer.tabnr == nil then
|
|
u.notify("Reviewer must be initialized first", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
-- Check that Diffview is the current view
|
|
local view = diffview_lib.get_current_view()
|
|
if view == nil then
|
|
u.notify("Comments should be left in the reviewer pane", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
-- Check that we are in the diffview tab
|
|
local tabnr = vim.api.nvim_get_current_tabpage()
|
|
if tabnr ~= reviewer.tabnr then
|
|
u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR)
|
|
return
|
|
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
|
|
end
|
|
end
|
|
|
|
local title = opts.discussion_id and "Reply" or "Comment"
|
|
local settings = opts.discussion_id ~= nil and state.settings.popup.reply or state.settings.popup.comment
|
|
|
|
M.current_win = vim.api.nvim_get_current_win()
|
|
M.comment_popup = Popup(u.create_popup_state(title, settings))
|
|
M.draft_popup = Popup(u.create_box_popup_state("Draft", false))
|
|
M.start_line, M.end_line = u.get_visual_selection_boundaries()
|
|
|
|
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 = "50%",
|
|
relative = "editor",
|
|
size = {
|
|
width = "50%",
|
|
height = "55%",
|
|
},
|
|
}, internal_layout)
|
|
|
|
miscellaneous.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup })
|
|
|
|
local range = opts.ranged and { start_line = M.start_line, end_line = M.end_line } or nil
|
|
local unlinked = opts.unlinked or false
|
|
|
|
---Keybinding for focus on draft section
|
|
state.set_popup_keymaps(M.draft_popup, function()
|
|
local text = u.get_buffer_text(M.comment_popup.bufnr)
|
|
confirm_create_comment(text, range, unlinked, opts.discussion_id)
|
|
vim.api.nvim_set_current_win(M.current_win)
|
|
end, miscellaneous.toggle_bool, miscellaneous.non_editable_popup_opts)
|
|
|
|
---Keybinding for focus on text section
|
|
state.set_popup_keymaps(M.comment_popup, function(text)
|
|
confirm_create_comment(text, range, unlinked, opts.discussion_id)
|
|
vim.api.nvim_set_current_win(M.current_win)
|
|
end, miscellaneous.attach_file, miscellaneous.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)
|
|
|
|
--Send back to previous window on close
|
|
vim.api.nvim_create_autocmd("BufHidden", {
|
|
buffer = M.draft_popup.bufnr,
|
|
callback = function()
|
|
vim.api.nvim_set_current_win(M.current_win)
|
|
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()
|
|
local has_clean_tree, err = git.has_clean_tree()
|
|
if err ~= nil then
|
|
return
|
|
end
|
|
|
|
local is_modified = vim.bo[0].modified
|
|
if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then
|
|
u.notify(
|
|
"Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.",
|
|
vim.log.levels.WARN
|
|
)
|
|
return
|
|
end
|
|
|
|
if not M.sha_exists() then
|
|
return
|
|
end
|
|
|
|
local layout = M.create_comment_layout({ ranged = false, unlinked = false })
|
|
if layout ~= nil then
|
|
layout:mount()
|
|
end
|
|
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()
|
|
if not u.check_visual_mode() then
|
|
return
|
|
end
|
|
if not M.sha_exists() then
|
|
return
|
|
end
|
|
|
|
local layout = M.create_comment_layout({ ranged = true, unlinked = false })
|
|
if layout ~= nil then
|
|
layout:mount()
|
|
end
|
|
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({ ranged = false, unlinked = true })
|
|
if layout ~= nil then
|
|
layout:mount()
|
|
end
|
|
end
|
|
|
|
---Given the current visually selected area of text, builds text to fill in the
|
|
---comment popup with a suggested change
|
|
---@return LineRange|nil
|
|
---@return integer
|
|
local build_suggestion = function()
|
|
local current_line = vim.api.nvim_win_get_cursor(0)[1]
|
|
M.start_line, M.end_line = u.get_visual_selection_boundaries()
|
|
|
|
local range_length = M.end_line - M.start_line
|
|
local backticks = "```"
|
|
local selected_lines = u.get_lines(M.start_line, M.end_line)
|
|
|
|
for _, line in ipairs(selected_lines) do
|
|
if string.match(line, "^```%S*$") then
|
|
backticks = "````"
|
|
break
|
|
end
|
|
end
|
|
|
|
local suggestion_start
|
|
if M.start_line == current_line then
|
|
suggestion_start = backticks .. "suggestion:-0+" .. range_length
|
|
elseif M.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, 0
|
|
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, range_length
|
|
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()
|
|
if not u.check_visual_mode() then
|
|
return
|
|
end
|
|
if not M.sha_exists() then
|
|
return
|
|
end
|
|
|
|
local suggestion_lines, range_length = build_suggestion()
|
|
|
|
local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false })
|
|
if layout ~= nil then
|
|
layout:mount()
|
|
end
|
|
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
|
|
|
|
---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
|