diff --git a/cmd/comment.go b/cmd/comment.go index d652e0f..3361906 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -44,6 +44,11 @@ type EditCommentRequest struct { DiscussionId string `json:"discussion_id"` } +type CommentResponse struct { + SuccessResponse + Comment *gitlab.Note `json:"note"` +} + func CommentHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodDelete: @@ -157,20 +162,25 @@ func EditComment(w http.ResponseWriter, r *http.Request) { Body: gitlab.String(editCommentRequest.Comment), } - _, res, err := c.git.Discussions.UpdateMergeRequestDiscussionNote(c.projectId, c.mergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) - - for k, v := range res.Header { - w.Header().Set(k, v[0]) - } + note, res, err := c.git.Discussions.UpdateMergeRequestDiscussionNote(c.projectId, c.mergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) if err != nil { c.handleError(w, err, "Could not edit comment", res.StatusCode) return } - response := SuccessResponse{ - Message: "Comment edited succesfully", - Status: http.StatusOK, + w.WriteHeader(res.StatusCode) + + if res.StatusCode != http.StatusOK { + c.handleError(w, errors.New("Non-200 status code recieved"), "Could not edit comment", res.StatusCode) + } + + response := CommentResponse{ + SuccessResponse: SuccessResponse{ + Message: "Comment edited succesfully", + Status: http.StatusOK, + }, + Comment: note, } json.NewEncoder(w).Encode(response) diff --git a/lua/gitlab/comment.lua b/lua/gitlab/comment.lua index 792cded..692e0d5 100644 --- a/lua/gitlab/comment.lua +++ b/lua/gitlab/comment.lua @@ -1,42 +1,24 @@ local Menu = require("nui.menu") local NuiTree = require("nui.tree") +local Popup = require("nui.popup") local job = require("gitlab.job") local state = require("gitlab.state") local u = require("gitlab.utils") +local discussions = require("gitlab.discussions") local keymaps = require("gitlab.keymaps") -local Popup = require("nui.popup") local M = {} -local commentPopup = Popup(u.create_popup_state("Comment", "40%", "60%")) -local editPopup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) - -M.line_status = nil +local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%")) +local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) +-- Function that fires to open the comment popup M.create_comment = function() if u.base_invalid() then return end - commentPopup:mount() - keymaps.set_popup_keymaps(commentPopup, M.confirm_create_comment) + comment_popup:mount() + keymaps.set_popup_keymaps(comment_popup, M.confirm_create_comment) end -M.find_deletion_commit = function(file) - local current_line = vim.api.nvim_get_current_line() - local command = string.format("git log -S '%s' %s", current_line, file) - local handle = io.popen(command) - local output = handle:read("*line") - if output == nil then - vim.notify("Error reading SHA of deletion commit", vim.log.levels.ERROR) - return "" - end - handle:close() - local words = {} - for word in output:gmatch("%S+") do - table.insert(words, word) - end - - return words[2] -end - --- Sends the comment to Gitlab +-- Actually sends the comment to Gitlab M.confirm_create_comment = function(text) if u.base_invalid() then return end local relative_file_path = u.get_relative_file_path() @@ -58,9 +40,13 @@ M.confirm_create_comment = function(text) local jsonTable = { line_number = current_line_number, file_name = relative_file_path, comment = text } local json = vim.json.encode(jsonTable) - job.run_job("comment", "POST", json) + job.run_job("comment", "POST", json, function() + vim.notify("Comment created") + discussions.refresh_tree() + end) end +-- Function to open the deletion popup M.delete_comment = function() local menu = Menu({ position = "50%", @@ -89,101 +75,107 @@ M.delete_comment = function() close = state.keymaps.dialogue.close, submit = state.keymaps.dialogue.submit, }, - on_submit = function(item) - if item.text == "Confirm" then - local note_id - local node = state.tree:get_node() - if node.is_note then - note_id = node:get_id() - end - local parentId = node:get_parent_id() - while (parentId ~= nil) do - node = state.tree:get_node(parentId) - parentId = node:get_parent_id() - if node.is_note then - note_id = node:get_id() - end - end - local discussion_id = node:get_id() - discussion_id = string.sub(discussion_id, 2) -- Remove the "-" at the start - note_id = tonumber(string.sub(note_id, 2)) -- Remove the "-" at the start - - local jsonTable = { discussion_id = discussion_id, note_id = note_id } - local json = vim.json.encode(jsonTable) - - job.run_job("comment", "DELETE", json, function(data) - vim.notify(data.message, vim.log.levels.INFO) - state.tree:remove_node("-" .. note_id) - local discussion_node = state.tree:get_node("-" .. discussion_id) - if not discussion_node:has_children() then - state.tree:remove_node("-" .. discussion_id) - end - state.tree:render() - end) - end - end, + on_submit = M.send_deletion }) menu:mount() end +-- Function to actually send the deletion to Gitlab +M.send_deletion = function(item) + if item.text == "Confirm" then + local current_node = state.tree:get_node() -M.edit_comment = function() - if u.base_invalid() then return end - local node = state.tree:get_node() - if node.is_discussion then return end - if node.is_body then - local parentId = node:get_parent_id() - node = state.tree:get_node(parentId) -- Get the node for the comment + local note_node = discussions.get_note_node(current_node) + local root_node = discussions.get_root_node(current_node) + + local jsonTable = { discussion_id = root_node.id, note_id = root_node.root_note_id or note_node.id } + local json = vim.json.encode(jsonTable) + + job.run_job("comment", "DELETE", json, function(data) + M.delete_node() + end) end +end - editPopup:mount() +-- Function that opens the edit popup from the discussion tree +M.edit_comment = function() + if u.base_invalid() then return end + local current_node = state.tree:get_node() + local note_node = discussions.get_note_node(current_node) + local root_node = discussions.get_root_node(current_node) - local note_id = tonumber(string.sub(node:get_id(), 2)) -- Remove the "-" at the start - local discussion_id = node:get_parent_id() - discussion_id = string.sub(discussion_id, 2) -- Remove the "-" at the start + edit_popup:mount() - state.ACTIVE_DISCUSSION = discussion_id - state.ACTIVE_NOTE = note_id - - local lines = {} - local childrenIds = node:get_child_ids() - for _, value in ipairs(childrenIds) do - local line = state.tree:get_node(value).text - table.insert(lines, line) + local lines = {} -- Gather all lines from immediate children that aren't note nodes + local children_ids = note_node:get_child_ids() + for _, child_id in ipairs(children_ids) do + local child_node = state.tree:get_node(child_id) + if (not child_node:has_children()) then + local line = state.tree:get_node(child_id).text + table.insert(lines, line) + end end local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) - keymaps.set_popup_keymaps(editPopup, M.send_edits) + keymaps.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id)) end -M.send_edits = function(text) - local escapedText = string.gsub(text, "\n", "\\n") - - local jsonTable = { discussion_id = state.ACTIVE_DISCUSSION, note_id = state.ACTIVE_NOTE, comment = escapedText } - local json = vim.json.encode(jsonTable) - - job.run_job("comment", "PATCH", json, function() - vim.schedule(function() - local node = state.tree:get_node("-" .. state.ACTIVE_NOTE) - local childrenIds = node:get_child_ids() - for _, value in ipairs(childrenIds) do - state.tree:remove_node(value) - end - - local newNoteTextNodes = {} - for bodyLine in text:gmatch("[^\n]+") do - table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) - end - - state.tree:set_nodes(newNoteTextNodes, "-" .. state.ACTIVE_NOTE) - - state.tree:render() - local buf = vim.api.nvim_get_current_buf() - u.darken_metadata(buf, '') - vim.notify("Edited comment!", vim.log.levels.INFO) +-- Function that actually makes the API call +M.send_edits = function(discussion_id, note_id) + return function(text) + local json_table = { + discussion_id = discussion_id, + note_id = note_id, + comment = text + } + local json = vim.json.encode(json_table) + job.run_job("comment", "PATCH", json, function(data) + vim.notify(data.message, vim.log.levels.INFO) + M.redraw_node(text) end) - end) + end +end + +-- Helpers +M.find_deletion_commit = function(file) + local current_line = vim.api.nvim_get_current_line() + local command = string.format("git log -S '%s' %s", current_line, file) + local handle = io.popen(command) + local output = handle:read("*line") + if output == nil then + vim.notify("Error reading SHA of deletion commit", vim.log.levels.ERROR) + return "" + end + handle:close() + local words = {} + for word in output:gmatch("%S+") do + table.insert(words, word) + end + + return words[2] +end + +M.redraw_node = function(text) + local current_node = state.tree:get_node() + local note_node = discussions.get_note_node(current_node) + + local childrenIds = note_node:get_child_ids() + for _, value in ipairs(childrenIds) do + state.tree:remove_node(value) + end + + local newNoteTextNodes = {} + for bodyLine in text:gmatch("[^\n]+") do + table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) + end + + state.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id) + + state.tree:render() + local buf = vim.api.nvim_get_current_buf() + u.darken_metadata(buf, '') + vim.notify("Edited comment!", vim.log.levels.INFO) end return M diff --git a/lua/gitlab/discussions.lua b/lua/gitlab/discussions.lua index f46681f..c59d083 100644 --- a/lua/gitlab/discussions.lua +++ b/lua/gitlab/discussions.lua @@ -1,108 +1,60 @@ -local u = require("gitlab.utils") -local NuiTree = require("nui.tree") -local NuiSplit = require("nui.split") -local job = require("gitlab.job") -local state = require("gitlab.state") -local Job = require("plenary.job") -local Popup = require("nui.popup") -local keymaps = require("gitlab.keymaps") +local u = require("gitlab.utils") +local NuiTree = require("nui.tree") +local NuiSplit = require("nui.split") +local job = require("gitlab.job") +local state = require("gitlab.state") +local Popup = require("nui.popup") +local keymaps = require("gitlab.keymaps") -local M = {} +local M = {} -local replyPopup = Popup(u.create_popup_state("Reply", "80%", "80%")) +local replyPopup = Popup(u.create_popup_state("Reply", "80%", "80%")) -M.reply = function() +M.reply = function(discussion_id) if u.base_invalid() then return end replyPopup:mount() - keymaps.set_popup_keymaps(replyPopup, M.send_reply) + keymaps.set_popup_keymaps(replyPopup, M.send_reply(discussion_id)) end -M.send_reply = function(text) - local escapedText = string.gsub(text, "\n", "\\n") - - local jsonTable = { discussion_id = state.ACTIVE_DISCUSSION, reply = escapedText } - local json = vim.json.encode(jsonTable) - - job.run_job("reply", "POST", json, function(data) - local note_node = M.build_note(data.note) - note_node:expand() - - state.tree:add_node(note_node, "-" .. state.ACTIVE_DISCUSSION) - vim.schedule(function() - state.tree:render() - local buf = vim.api.nvim_get_current_buf() - u.darken_metadata(buf, '') - vim.notify("Sent reply!", vim.log.levels.INFO) +M.send_reply = function(discussion_id) + return function(text) + local jsonTable = { discussion_id = discussion_id, reply = text } + local json = vim.json.encode(jsonTable) + job.run_job("reply", "POST", json, function(data) + M.add_note_to_tree(data.note, discussion_id) end) - end) + end end -- Places all of the discussions into a readable list -M.list_discussions = function() +M.list_discussions = function() if u.base_invalid() then return end - Job:new({ - command = "curl", - args = { "-s", string.format("localhost:%s/discussions", state.PORT) }, - on_stdout = function(_, output) - local data_ok, data = pcall(vim.json.decode, output) - if data_ok and data ~= nil then - local status = (data.status >= 200 and data.status < 300) and "success" or "error" - if status == "error" then - vim.notify("Could not fetch discussions!", vim.log.levels.ERROR) - return - end - M.discussions = data.discussions - vim.schedule(function() - if type(data.discussions) ~= "table" then - vim.notify("No discussions for this MR") - return - end + job.run_job("discussions", "GET", nil, function(data) + if type(data.discussions) ~= "table" then + vim.notify("No discussions for this MR") + return + end - local splitState = state.DISCUSSION_SPLIT - splitState.buf_options = { modifiable = false } - local split = NuiSplit(splitState) - split:mount() + local splitState = state.DISCUSSION_SPLIT + splitState.buf_options = { modifiable = false } + local split = NuiSplit(splitState) + split:mount() - local buf = split.bufnr - local allDiscussions = {} - for i, discussion in ipairs(data.discussions) do - local discussionChildren = {} - for _, note in ipairs(discussion.notes) do - local note_node = M.build_note(note) - if i == 1 then - note_node:expand() - end - table.insert(discussionChildren, note_node) - end - local discussionNode = NuiTree.Node({ - text = discussion.id, - id = discussion.id, - is_discussion = true - }, - discussionChildren) - if i == 1 then - discussionNode:expand() - end - table.insert(allDiscussions, discussionNode) - end - state.tree = NuiTree({ nodes = allDiscussions, bufnr = buf }) + local buf = split.bufnr + state.SPLIT_BUF = buf - M.set_tree_keymaps(buf) + local tree_nodes = M.add_discussions_to_table(data.discussions) - state.tree:render() - vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') - u.darken_metadata(buf, '') - end) - end - end, - on_stderr = function(_, output) - vim.notify("Could not run approve command!", vim.log.levels.ERROR) - error(output) - end, - }):start() + state.tree = NuiTree({ nodes = tree_nodes, bufnr = buf }) + M.set_tree_keymaps(buf) + + state.tree:render() + vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') + u.darken_metadata(buf, '') + end) end -M.jump_to_file = function() +M.jump_to_file = function() local node = state.tree:get_node() if node == nil then return end @@ -114,23 +66,11 @@ M.jump_to_file = function() end end - local childrenIds = node:get_child_ids() - -- We have selected a note node - if node.file_name ~= nil then - u.jump_to_file(node.file_name, node.line_number) - elseif node.is_body then - local parentId = node:get_parent_id() - local parent = state.tree:get_node(parentId) - if parent == nil then return end - u.jump_to_file(parent.file_name, parent.line_number) - else - local firstChild = state.tree:get_node(childrenIds[1]) - if firstChild == nil then return end - u.jump_to_file(firstChild.file_name, firstChild.line_number) - end + local discussion_node = M.get_root_node(node) + u.jump_to_file(discussion_node.file_name, discussion_node.line_number) end -M.set_tree_keymaps = function(buf) +M.set_tree_keymaps = function(buf) -- Jump to file location where comment was left vim.keymap.set('n', state.keymaps.discussion_tree.jump_to_location, function() M.jump_to_file() @@ -142,7 +82,7 @@ M.set_tree_keymaps = function(buf) vim.keymap.set('n', state.keymaps.discussion_tree.delete_comment, function() require("gitlab.comment").delete_comment() - end) + end, { buffer = true }) -- Expand/collapse the current node vim.keymap.set('n', state.keymaps.discussion_tree.toggle_node, function() @@ -162,7 +102,6 @@ M.set_tree_keymaps = function(buf) node:expand() end - state.tree:render() u.darken_metadata(buf, '') end, @@ -171,47 +110,132 @@ M.set_tree_keymaps = function(buf) vim.keymap.set('n', 'r', function() local node = state.tree:get_node() if node == nil then return end - - -- Get closest discussion parent - if node.is_body then - local parentId = node:get_parent_id() - local parent = state.tree:get_node(parentId) - if parent == nil then return end - parentId = parent:get_parent_id() - parent = state.tree:get_node(parentId) - if parent == nil then return end - node = parent - elseif node.is_note then - local parentId = node:get_parent_id() - local parent = state.tree:get_node(parentId) - if parent == nil then return end - node = parent - end - - state.ACTIVE_DISCUSSION = node.id - M.reply() + local discussion_node = M.get_root_node(node) + M.reply(tostring(discussion_node.id)) end, { buffer = true }) end -M.build_note = function(note) - local noteTextNodes = {} +M.get_root_node = function(node) + if (not node.is_root) then + local parent_id = node:get_parent_id() + return M.get_root_node(state.tree:get_node(parent_id)) + else + return node + end +end + +M.get_note_node = function(node) + if (not node.is_note) then + local parent_id = node:get_parent_id() + if parent_id == nil then return node end + return M.get_note_node(state.tree:get_node(parent_id)) + else + return node + end +end + +M.build_note_body = function(note) + local text_nodes = {} for bodyLine in note.body:gmatch("[^\n]+") do - table.insert(noteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) + table.insert(text_nodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) end local noteHeader = "@" .. - note.author.username .. " on " .. u.format_date(note.created_at) + note.author.username .. " " .. u.format_date(note.created_at) + return noteHeader, text_nodes +end + +M.build_note = function(note) + local text, text_nodes = M.build_note_body(note) local line_number = note.position.new_line or note.position.old_line local note_node = NuiTree.Node( { - text = noteHeader, + text = text, id = note.id, file_name = note.position.new_path, line_number = line_number, is_note = true - }, noteTextNodes) + }, text_nodes) - return note_node + return note_node, text, text_nodes +end + +M.add_note_to_tree = function(note, discussion_id) + local note_node = M.build_note(note) + note_node:expand() + state.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) + state.tree:render() + local buf = vim.api.nvim_get_current_buf() + u.darken_metadata(buf, '') + vim.notify("Sent reply!", vim.log.levels.INFO) +end + +M.refresh_tree = function() + job.run_job("discussions", "GET", nil, function(data) + if type(data.discussions) ~= "table" then + vim.notify("No discussions for this MR") + return + end + + if not state.SPLIT_BUF then return end + + vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', true) + vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', false) + vim.api.nvim_buf_set_lines(state.SPLIT_BUF, 0, -1, false, {}) + vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', true) + vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', false) + + local tree_nodes = M.add_discussions_to_table(data.discussions) + state.tree = NuiTree({ nodes = tree_nodes, bufnr = state.SPLIT_BUF }) + M.set_tree_keymaps(state.SPLIT_BUF) + state.tree:render() + vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'filetype', 'markdown') + u.darken_metadata(state.SPLIT_BUF, '') + end) +end + +M.add_discussions_to_table = function(discussions) + local t = {} + for _, discussion in ipairs(discussions) do + local discussion_children = {} + + -- These properties are filled in by the first note + local root_text = '' + local root_note_id = '' + local root_line_number = 0 + local root_file_name = '' + local root_id = 0 + local root_text_nodes = {} + + for j, note in ipairs(discussion.notes) do + if j == 1 then + __, root_text, root_text_nodes = M.build_note(note) + root_file_name = note.position.new_path + root_line_number = note.position.new_line or note.position.old_line + root_id = discussion.id + root_note_id = note.id + else -- Otherwise insert it as a child node... + local note_node = M.build_note(note) + table.insert(discussion_children, note_node) + end + end + + -- Creates the first node in the discussion, and attaches children + local body = u.join_tables(root_text_nodes, discussion_children) + local root_node = NuiTree.Node({ + text = root_text, + is_note = true, + is_root = true, + id = root_id, + root_note_id = root_note_id, + file_name = root_file_name, + line_number = root_line_number, + }, body) + + table.insert(t, root_node) + end + + return t end return M diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index f6bd737..059fa21 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -95,12 +95,33 @@ local base_invalid = function() end local format_date = function(date_string) + local date_table = os.date("!*t") local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)") local date = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec }) - -- Format date into human-readable string without leading zeros - local formatted_date = os.date("%A, %B %e at %l:%M %p", date) - return formatted_date + local current_date = os.time({ + year = date_table.year, + month = date_table.month, + day = date_table.day, + hour = date_table.hour, + min = date_table.min, + sec = date_table.sec + }) + + local time_diff = current_date - date + + if time_diff < 60 then + return time_diff .. " seconds ago" + elseif time_diff < 3600 then + return math.floor(time_diff / 60) .. " minutes ago" + elseif time_diff < 86400 then + return math.floor(time_diff / 3600) .. " hours ago" + elseif time_diff < 2592000 then + return math.floor(time_diff / 86400) .. " days ago" + else + local formatted_date = os.date("%A, %B %e", date) + return formatted_date + end end local add_comment_sign = function(line_number) @@ -222,7 +243,16 @@ local current_file_path = function() return vim.fn.fnamemodify(path, ':p') end +-- Function to join two tables +function join_tables(table1, table2) + for _, value in ipairs(table2) do + table.insert(table1, value) + end + return table1 +end + +M.join_tables = join_tables M.get_relative_file_path = get_relative_file_path M.get_current_line_number = get_current_line_number M.get_buffer_text = get_buffer_text