diff --git a/README.md b/README.md index c9189ac..a8a3fb0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,11 @@ This Neovim plugin is designed to make it easy to review Gitlab MRs from within - Create, edit, delete, and reply to comments on an MR - Read and Edit an MR description -- Approve/Revoke Approval for an MR +- Approve or revoke ppproval for an MR - Add or remove reviewers and assignees +- Resolve and unresolve discussion threads + +And a lot more! https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dfd3aa8a-6fc4-4e43-8d2f-489df0745822 @@ -54,7 +57,7 @@ use { ## Configuration -This plugin requires a `.gitlab.nvim` file in the root of the local Gitlab directory. Provide this file with values required to connect to your gitlab instance (gitlab_url is optional, use only for self-hosted instances): +This plugin requires a `.gitlab.nvim` file in the root of the local Gitlab directory. Provide this file with values required to connect to your gitlab instance (gitlab_url is optional, use ONLY for self-hosted instances): ``` project_id=112415 @@ -92,14 +95,15 @@ require("gitlab").setup({ perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) }, discussion_tree = { -- The discussion tree that holds all comments - jump_to_location = "o", - edit_comment = "e", - delete_comment = "dd", - reply_to_comment = "r", - toggle_node = "t", + jump_to_location = "o", -- Jump to comment location in file + edit_comment = "e", -- Edit coment + delete_comment = "dd", -- Delete comment + reply_to_comment = "r", -- Reply to comment + toggle_resolved = "p" -- Toggles the resolved status of the discussion + toggle_node = "t", -- Opens or closes the discussion position = "left", -- "top", "right", "bottom" or "left" + relative = "editor" -- Position of tree split relative to "editor" or "window" size = "20%", -- Size of split - relative = "editor" -- Position relative to "editor" or "window" }, dialogue = { -- The confirmation dialogue for deleting comments focus_next = { "j", "", "" }, @@ -107,6 +111,10 @@ require("gitlab").setup({ close = { "", "" }, submit = { "", "" }, } + }, + symbols = { + resolved = '✓', -- Symbol to show next to resolved discussions + unresolved = '✖', -- Symbol to show next to unresolved discussions } }) ``` @@ -179,6 +187,7 @@ Within the discussion tree, there are several functions that you can call, howev require("gitlab").delete_comment() require("gitlab").edit_comment() require("gitlab").reply() +require("gitlab").toggle_resolved() ``` ## Keybindings diff --git a/cmd/comment.go b/cmd/comment.go index 9849ff6..581b30b 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -42,6 +42,7 @@ type EditCommentRequest struct { Comment string `json:"comment"` NoteId int `json:"note_id"` DiscussionId string `json:"discussion_id"` + Resolved bool `json:"resolved"` } type CommentResponse struct { @@ -158,26 +159,34 @@ func EditComment(w http.ResponseWriter, r *http.Request) { return } - options := gitlab.UpdateMergeRequestDiscussionNoteOptions{ - Body: gitlab.String(editCommentRequest.Comment), + options := gitlab.UpdateMergeRequestDiscussionNoteOptions{} + + /* The PATCH can either be to the resolved status of + the discussion or or the text of the comment */ + msg := "edit comment" + if editCommentRequest.Comment == "" { + options.Resolved = &editCommentRequest.Resolved + msg = "update discussion status" + } else { + options.Body = gitlab.String(editCommentRequest.Comment) } 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) + c.handleError(w, err, "Could not "+msg, res.StatusCode) return } 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) + c.handleError(w, errors.New("Non-200 status code recieved"), "Could not "+msg, res.StatusCode) } response := CommentResponse{ SuccessResponse: SuccessResponse{ - Message: "Comment edited succesfully", + Message: "Comment updated succesfully", Status: http.StatusOK, }, Comment: note, diff --git a/lua/gitlab/comment.lua b/lua/gitlab/comment.lua index ba8af6d..32c71ef 100644 --- a/lua/gitlab/comment.lua +++ b/lua/gitlab/comment.lua @@ -143,11 +143,28 @@ M.send_edits = function(discussion_id, note_id) 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) + M.redraw_text(text) end) end end +M.toggle_resolved = function() + local note = state.tree:get_node() + if not note.resolvable then return end + + local json_table = { + discussion_id = note.id, + note_id = note.root_note_id, + resolved = not note.resolved, + } + + local json = vim.json.encode(json_table) + job.run_job("comment", "PATCH", json, function(data) + vim.notify(data.message, vim.log.levels.INFO) + M.update_resolved_status(note, not note.resolved) + end) +end + -- Helpers M.find_deletion_commit = function(file) local current_line = vim.api.nvim_get_current_line() @@ -167,7 +184,35 @@ M.find_deletion_commit = function(file) return words[2] end -M.redraw_node = function(text) +M.update_resolved_status = function(note, mark_resolved) + local current_text = state.tree.nodes.by_id["-" .. note.id].text + local target = mark_resolved and 'resolved' or 'unresolved' + local current = mark_resolved and 'unresolved' or 'resolved' + + local function set_property(key, val) + state.tree.nodes.by_id["-" .. note.id][key] = val + end + + local has_symbol = function(s) + return state.SYMBOLS[s] ~= nil and state.SYMBOLS[s] ~= '' + end + + set_property('resolved', mark_resolved) + + if not has_symbol(current) and not has_symbol(target) then return end + + if not has_symbol(current) and has_symbol(target) then + set_property('text', (current_text .. " " .. state.SYMBOLS[target])) + elseif has_symbol(current) and not has_symbol(target) then + set_property('text', u.remove_last_chunk(current_text)) + else + set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.SYMBOLS[target])) + end + + state.tree:render() +end + +M.redraw_text = function(text) local current_node = state.tree:get_node() local note_node = discussions.get_note_node(current_node) diff --git a/lua/gitlab/discussions.lua b/lua/gitlab/discussions.lua index bb7608e..67968d2 100644 --- a/lua/gitlab/discussions.lua +++ b/lua/gitlab/discussions.lua @@ -35,7 +35,7 @@ M.list_discussions = function() return end - local splitState = state.DISCUSSION_SPLIT + local splitState = state.DISCUSSION.SPLIT splitState.buf_options = { modifiable = false } local split = NuiSplit(splitState) split:mount() @@ -84,6 +84,10 @@ M.set_tree_keymaps = function(buf) require("gitlab.comment").delete_comment() end, { buffer = true }) + vim.keymap.set('n', state.keymaps.discussion_tree.toggle_resolved, function() + require("gitlab.comment").toggle_resolved() + end, { buffer = true }) + -- Expand/collapse the current node vim.keymap.set('n', state.keymaps.discussion_tree.toggle_node, function() local node = state.tree:get_node() @@ -134,7 +138,7 @@ M.get_note_node = function(node) end end -M.build_note_body = function(note) +M.build_note_body = function(note, resolve_info) local text_nodes = {} for bodyLine in note.body:gmatch("[^\n]+") do local line = u.attach_uuid(bodyLine) @@ -145,23 +149,26 @@ M.build_note_body = function(note) }, {})) end - local noteHeader = "@" .. - note.author.username .. " " .. u.format_date(note.created_at) + local resolve_symbol = '' + if resolve_info ~= nil and resolve_info.resolvable then + resolve_symbol = resolve_info.resolved and state.SYMBOLS.resolved or state.SYMBOLS.unresolved + end + + local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol return noteHeader, text_nodes end -M.build_note = function(note) - local text, text_nodes = M.build_note_body(note) +M.build_note = function(note, resolve_info) + local text, text_nodes = M.build_note_body(note, resolve_info) local line_number = note.position.new_line or note.position.old_line - local note_node = NuiTree.Node( - { - text = text, - id = note.id, - file_name = note.position.new_path, - line_number = line_number, - is_note = true - }, text_nodes) + local note_node = NuiTree.Node({ + text = text, + id = note.id, + file_name = note.position.new_path, + line_number = line_number, + is_note = true, + }, text_nodes) return note_node, text, text_nodes end @@ -212,14 +219,18 @@ M.add_discussions_to_table = function(discussions) local root_file_name = '' local root_id = 0 local root_text_nodes = {} + local resolvable = false + local resolved = false for j, note in ipairs(discussion.notes) do if j == 1 then - __, root_text, root_text_nodes = M.build_note(note) + __, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable }) 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 + resolvable = note.resolvable + resolved = note.resolved else -- Otherwise insert it as a child node... local note_node = M.build_note(note) table.insert(discussion_children, note_node) @@ -236,6 +247,8 @@ M.add_discussions_to_table = function(discussions) root_note_id = root_note_id, file_name = root_file_name, line_number = root_line_number, + resolvable = resolvable, + resolved = resolved }, body) table.insert(t, root_node) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 845cd5d..2118c58 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -43,10 +43,11 @@ local M = {} M.summary = ensureState(summary.summary) M.approve = ensureState(job.approve) M.revoke = ensureState(job.revoke) -M.create_comment = ensureState(comment.create_comment) M.list_discussions = ensureState(discussions.list_discussions) +M.create_comment = ensureState(comment.create_comment) M.edit_comment = ensureState(comment.edit_comment) M.delete_comment = ensureState(comment.delete_comment) +M.toggle_resolved = ensureState(comment.toggle_resolved) M.add_reviewer = ensureProjectMembers(ensureState(assignees_and_reviewers.add_reviewer)) M.delete_reviewer = ensureProjectMembers(ensureState(assignees_and_reviewers.delete_reviewer)) M.add_assignee = ensureProjectMembers(ensureState(assignees_and_reviewers.add_assignee)) @@ -160,10 +161,17 @@ M.setPluginConfiguration = function(args) -- Configuration for the plugin, such as port of server, layout, etc state.PORT = args.port or 21036 state.LOG_PATH = args.log_path or (vim.fn.stdpath("cache") .. "/gitlab.nvim.log") - state.DISCUSSION_SPLIT = { - relative = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.relative or "editor", - position = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.position or "left", - size = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.size or "20%", + state.DISCUSSION = { + SPLIT = { + relative = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.relative or "editor", + position = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.position or "left", + size = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.size or "20%", + } + } + + state.SYMBOLS = { + resolved = (args.symbols and args.symbols.resolved or '✓'), + unresolved = (args.symbols and args.symbols.unresolved or '') } return true diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index e1dd299..44fb363 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -17,6 +17,7 @@ M.keymaps = { delete_comment = "dd", reply_to_comment = "r", toggle_node = "t", + toggle_resolved = "p" }, dialogue = { focus_next = { "j", "", "" }, diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index b780fc7..5ef7ad3 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -284,7 +284,17 @@ local extract = function(t, property) return resultTable end +local remove_last_chunk = function(sentence) + local words = {} + for word in sentence:gmatch("%S+") do + table.insert(words, word) + end + table.remove(words, #words) + local sentence_without_last = table.concat(words, " ") + return sentence_without_last +end +M.remove_last_chunk = remove_last_chunk M.extract = extract M.contains = contains M.attach_uuid = attach_uuid