diff --git a/README.md b/README.md index 0284c58..9d1f028 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dfd3aa8a-6fc4-4e43 ## Requirements -- Go +- Go >= v1.19 - make (for install) - delta @@ -92,6 +92,7 @@ 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 + blacklist = {}, -- List of usernames to remove from tree (bots, CI, etc) jump_to_file = "o", -- Jump to comment location in file jump_to_reviewer = "m", -- Jump to the location in the reviewer window edit_comment = "e", -- Edit coment @@ -160,6 +161,12 @@ require("gitlab").reply() require("gitlab").toggle_resolved() ``` +If you'd like to create a note in an MR (like a comment, but not linked to a specific line) call the `create_note()` command. Similar commands are available on the note tree, which is visible next to the discussion tree for comments. + +```lua +require("gitlab").create_note() +``` + You can approve or revoke approval for an MR: ```lua diff --git a/cmd/comment.go b/cmd/comment.go index 2d5aa4e..1908e7b 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -36,7 +36,8 @@ type EditCommentRequest struct { type CommentResponse struct { SuccessResponse - Comment *gitlab.Note `json:"note"` + Comment *gitlab.Note `json:"note"` + Discussion *gitlab.Discussion `json:"discussion"` } func CommentHandler(w http.ResponseWriter, r *http.Request) { @@ -107,24 +108,26 @@ func PostComment(w http.ResponseWriter, r *http.Request) { return } - position := &gitlab.NotePosition{ - PositionType: "text", - StartSHA: postCommentRequest.StartCommitSHA, - HeadSHA: postCommentRequest.HeadCommitSHA, - BaseSHA: postCommentRequest.BaseCommitSHA, - NewPath: postCommentRequest.FileName, - OldPath: postCommentRequest.FileName, - NewLine: postCommentRequest.NewLine, - OldLine: postCommentRequest.OldLine, + opt := gitlab.CreateMergeRequestDiscussionOptions{ + Body: &postCommentRequest.Comment, } - discussion, _, err := c.git.Discussions.CreateMergeRequestDiscussion( - c.projectId, - c.mergeId, - &gitlab.CreateMergeRequestDiscussionOptions{ - Body: &postCommentRequest.Comment, - Position: position, - }) + /* 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, + NewLine: postCommentRequest.NewLine, + OldLine: postCommentRequest.OldLine, + } + } + + discussion, _, err := c.git.Discussions.CreateMergeRequestDiscussion(c.projectId, c.mergeId, &opt) if err != nil { c.handleError(w, err, "Could not create comment", http.StatusBadRequest) @@ -136,7 +139,8 @@ func PostComment(w http.ResponseWriter, r *http.Request) { Message: "Comment updated succesfully", Status: http.StatusOK, }, - Comment: discussion.Notes[0], + Comment: discussion.Notes[0], + Discussion: discussion, } json.NewEncoder(w).Encode(response) diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go index c193933..c091a57 100644 --- a/cmd/list_discussions.go +++ b/cmd/list_discussions.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io" "net/http" "sort" @@ -11,13 +12,18 @@ import ( "github.com/xanzy/go-gitlab" ) -type SortableDiscussions []*gitlab.Discussion +type DiscussionsRequest struct { + Blacklist []string `json:"blacklist"` +} type DiscussionsResponse struct { SuccessResponse - Discussions []*gitlab.Discussion `json:"discussions"` + Discussions []*gitlab.Discussion `json:"discussions"` + UnlinkedDiscussions []*gitlab.Discussion `json:"unlinked_discussions"` } +type SortableDiscussions []*gitlab.Discussion + func (n SortableDiscussions) Len() int { return len(n) } @@ -26,14 +32,13 @@ func (d SortableDiscussions) Less(i int, j int) bool { iTime := d[i].Notes[len(d[i].Notes)-1].CreatedAt jTime := d[j].Notes[len(d[j].Notes)-1].CreatedAt return iTime.After(*jTime) - } func (n SortableDiscussions) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (c *Client) ListDiscussions() ([]*gitlab.Discussion, int, error) { +func (c *Client) ListDiscussions(blacklist []string) ([]*gitlab.Discussion, []*gitlab.Discussion, int, error) { mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{ Page: 1, @@ -42,36 +47,59 @@ func (c *Client) ListDiscussions() ([]*gitlab.Discussion, int, error) { discussions, res, err := c.git.Discussions.ListMergeRequestDiscussions(c.projectId, c.mergeId, &mergeRequestDiscussionOptions, nil) if err != nil { - return nil, res.Response.StatusCode, fmt.Errorf("Listing discussions failed: %w", err) + return nil, nil, res.Response.StatusCode, fmt.Errorf("Listing discussions failed: %w", err) } - var realDiscussions []*gitlab.Discussion - for i := 0; i < len(discussions); i++ { - notes := discussions[i].Notes - for j := 0; j < len(notes); j++ { - if notes[j].Type == gitlab.NoteTypeValue("DiffNote") { - realDiscussions = append(realDiscussions, discussions[i]) + /* Filter out any discussions started by a blacklisted user + and system discussions, then return them sorted by created date */ + var unlinkedDiscussions []*gitlab.Discussion + var linkedDiscussions []*gitlab.Discussion + for _, discussion := range discussions { + if Contains(blacklist, discussion.Notes[0].Author.Username) > -1 { + continue + } + for _, note := range discussion.Notes { + if note.Type == gitlab.NoteTypeValue("DiffNote") { + linkedDiscussions = append(linkedDiscussions, discussion) + break + } else if note.System == false && note.Position == nil { + unlinkedDiscussions = append(unlinkedDiscussions, discussion) break } } } - sortedDiscussions := SortableDiscussions(realDiscussions) - sort.Sort(sortedDiscussions) + sortedLinkedDiscussions := SortableDiscussions(linkedDiscussions) + sortedUnlinkedDiscussions := SortableDiscussions(unlinkedDiscussions) - return sortedDiscussions, http.StatusOK, nil + sort.Sort(sortedLinkedDiscussions) + sort.Sort(sortedUnlinkedDiscussions) + + return sortedLinkedDiscussions, sortedUnlinkedDiscussions, http.StatusOK, nil } func ListDiscussionsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") c := r.Context().Value("client").(Client) - if r.Method != http.MethodGet { + if r.Method != http.MethodPost { c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } - msg, status, err := c.ListDiscussions() + body, err := io.ReadAll(r.Body) + + if err != nil { + c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + } + + var requestBody DiscussionsRequest + err = json.Unmarshal(body, &requestBody) + if err != nil { + c.handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) + } + + linkedDiscussions, unlinkedDiscussions, status, err := c.ListDiscussions(requestBody.Blacklist) if err != nil { c.handleError(w, err, "Could not list discussions", http.StatusBadRequest) @@ -85,7 +113,8 @@ func ListDiscussionsHandler(w http.ResponseWriter, r *http.Request) { Message: "Discussions successfully fetched.", Status: http.StatusOK, }, - Discussions: msg, + Discussions: linkedDiscussions, + UnlinkedDiscussions: unlinkedDiscussions, } json.NewEncoder(w).Encode(response) diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..00251b8 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,10 @@ +package main + +func Contains[T comparable](elems []T, v T) int { + for i, s := range elems { + if v == s { + return i + } + } + return -1 +} diff --git a/lua/gitlab/actions/assignees_and_reviewers.lua b/lua/gitlab/actions/assignees_and_reviewers.lua index 5ba60c6..d9d31ec 100644 --- a/lua/gitlab/actions/assignees_and_reviewers.lua +++ b/lua/gitlab/actions/assignees_and_reviewers.lua @@ -34,8 +34,8 @@ M.add_popup = function(type) if not choice then return end local current_ids = u.extract(current, 'id') table.insert(current_ids, choice.id) - local json = vim.json.encode({ ids = current_ids }) - job.run_job("/mr/" .. type, "PUT", json, function(data) + local body = { ids = current_ids } + job.run_job("/mr/" .. type, "PUT", body, function(data) vim.notify(data.message, vim.log.levels.INFO) state.INFO[plural] = data[plural] end) @@ -53,8 +53,8 @@ M.delete_popup = function(type) }, function(choice) if not choice then return end local ids = u.extract(M.filter_eligible(current, { choice }), 'id') - local json = vim.json.encode({ ids = ids }) - job.run_job("/mr/" .. type, "PUT", json, function(data) + local body = { ids = ids } + job.run_job("/mr/" .. type, "PUT", body, function(data) vim.notify(data.message, vim.log.levels.INFO) state.INFO[plural] = data[plural] end) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 7ac9df4..949b9c5 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -10,15 +10,33 @@ 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%")) -- 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, M.confirm_create_comment) + state.set_popup_keymaps(comment_popup, function(text) + M.confirm_create_comment(text) + end) +end + +M.create_note = function() + note_popup:mount() + state.set_popup_keymaps(note_popup, function(text) + M.confirm_create_comment(text, true) + end) end -- This function (settings.popup.perform_action) will send the comment to the Go server -M.confirm_create_comment = function(text) +M.confirm_create_comment = function(text, unlinked) + if unlinked then + local body = { comment = text } + job.run_job("/comment", "POST", body, function(data) + discussions.add_discussion({ data = data, unlinked = true }) + end) + return + end + local file_name, line_numbers, error = reviewer.get_location() if error then @@ -42,7 +60,7 @@ M.confirm_create_comment = function(text) end local revision = state.MR_REVISIONS[1] - local jsonTable = { + local body = { comment = text, file_name = file_name, old_line = line_numbers.old_line, @@ -53,11 +71,8 @@ M.confirm_create_comment = function(text) type = "modification" } - local json = vim.json.encode(jsonTable) - - job.run_job("/comment", "POST", json, function(data) - vim.notify("Comment created") - discussions.refresh_tree() + job.run_job("/comment", "POST", body, function(data) + discussions.add_discussion({ data = data, unlinked = false }) end) end diff --git a/lua/gitlab/actions/discussions.lua b/lua/gitlab/actions/discussions.lua index cc033e2..ad267f3 100644 --- a/lua/gitlab/actions/discussions.lua +++ b/lua/gitlab/actions/discussions.lua @@ -1,10 +1,11 @@ -- This module is responsible for the discussion tree. That includes things like -- editing existing notes in the tree, replying to notes in the tree, -- and marking discussions as resolved/unresolved. +local Split = require("nui.split") local Popup = require("nui.popup") local Menu = require("nui.menu") local NuiTree = require("nui.tree") -local NuiSplit = require("nui.split") +local Layout = require("nui.layout") local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") @@ -14,100 +15,79 @@ local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%")) local M = { - split_visible = false, - split = nil, - split_buf = nil, - tree = nil + layout_visible = false, + layout = nil, + layout_buf = nil, + discussions = {}, + unlinked_discussions = {}, + linked_section_bufnr = -1, + unlinked_section_bufnr = -1, } -M.set_tree_keymaps = function() - vim.keymap.set('n', state.settings.discussion_tree.jump_to_file, M.jump_to_file, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.jump_to_reviewer, M.jump_to_reviewer, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.edit_comment, M.edit_comment, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.delete_comment, M.delete_comment, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.toggle_resolved, M.toggle_resolved, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.toggle_node, M.toggle_node, { buffer = true }) - vim.keymap.set('n', state.settings.discussion_tree.reply, M.reply, { buffer = true }) -end - --- Opens the discussion tree, sets the keybindings, +-- Opens the discussion tree, sets the keybindings. It also +-- creates the tree for notes (which are not linked to specific lines of code) M.toggle = function() - if M.split_visible then - M.split:hide() - M.split_visible = false + if M.layout_visible then + M.layout:unmount() + M.layout_visible = false return end - if M.split then - M.split:show() - M.split_visible = true - return - end + local linked_section, unlinked_section, layout = M.create_layout() + M.linked_section_bufnr = linked_section.bufnr + M.unlinked_section_bufnr = unlinked_section.bufnr - local split = NuiSplit({ - buf_options = { modifiable = false }, - relative = state.settings.discussion_tree.relative, - position = state.settings.discussion_tree.position, - size = state.settings.discussion_tree.size, - }) - - split:mount() - M.split = split - M.split_visible = true - M.split_buf = split.bufnr - state.discussion_buf = split.bufnr - - vim.api.nvim_create_autocmd({ "QuitPre", "BufDelete", "BufUnload" }, { - buffer = split.bufnr, - callback = function() - M.split = nil - M.split_visible = false - M.split_buf = nil - end, - desc = "Handles users who close the split in non-keybinding fashion", - }) - - local buf = M.split.bufnr - - job.run_job("/discussions", "GET", nil, function(data) - if type(data.discussions) ~= "table" then - vim.notify("No discussions for this MR", vim.log.levels.WARN) + job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) + if type(data.discussions) ~= "table" and type(data.unlinked_discussions) ~= "table" then + vim.notify("No discussions or notes for this MR", vim.log.levels.WARN) return end - local tree_nodes = M.add_discussions_to_table(data.discussions) + layout:mount() + layout:show() - M.tree = NuiTree({ nodes = tree_nodes, bufnr = buf }) - M.set_tree_keymaps() + M.layout = layout + M.layout_visible = true + M.layout_buf = layout.bufnr + state.discussion_buf = layout.bufnr - M.tree:render() - vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown') + M.discussions = data.discussions + M.unlinked_discussions = data.unlinked_discussions + + if type(data.discussions) == "table" then M.rebuild_discussion_tree() end + if type(data.unlinked_discussions) == "table" then M.rebuild_unlinked_discussion_tree() end + + M.switch_can_edit_bufs(true) + M.add_empty_titles({ + { linked_section.bufnr, data.discussions, "No Discussions for this MR" }, + { unlinked_section.bufnr, data.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" } + }) + M.switch_can_edit_bufs(false) end) end -- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree. -M.reply = function() - local node = M.tree:get_node() - local discussion_node = M.get_root_node(node) +M.reply = function(tree) + local node = tree:get_node() + local discussion_node = M.get_root_node(tree, node) local id = tostring(discussion_node.id) reply_popup:mount() - state.set_popup_keymaps(reply_popup, M.send_reply(id)) + state.set_popup_keymaps(reply_popup, M.send_reply(tree, id)) end -- This function will send the reply to the Go API -M.send_reply = function(discussion_id) - print(discussion_id) +M.send_reply = function(tree, 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) + local body = { discussion_id = discussion_id, reply = text } + job.run_job("/reply", "POST", body, function(data) + vim.notify("Sent reply!", vim.log.levels.INFO) + M.add_reply_to_tree(tree, data.note, discussion_id) end) end end -- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment -M.delete_comment = function() +M.delete_comment = function(tree, unlinked) local menu = Menu({ position = "50%", size = { @@ -135,100 +115,114 @@ M.delete_comment = function() close = state.settings.dialogue.close, submit = state.settings.dialogue.submit, }, - on_submit = M.send_deletion + on_submit = function(item) + M.send_deletion(tree, item, unlinked) + end }) menu:mount() end -- This function will actually send the deletion to Gitlab --- when you make a selection -M.send_deletion = function(item) +-- when you make a selection, and re-render the tree +M.send_deletion = function(tree, item, unlinked) if item.text == "Confirm" then - local current_node = M.tree:get_node() + local current_node = tree:get_node() - local note_node = M.get_note_node(current_node) - local root_node = M.get_root_node(current_node) + local note_node = M.get_note_node(tree, current_node) + local root_node = M.get_root_node(tree, current_node) local note_id = note_node.is_root and root_node.root_note_id or note_node.id - local jsonTable = { discussion_id = root_node.id, note_id = note_id } - local json = vim.json.encode(jsonTable) + local body = { discussion_id = root_node.id, note_id = note_id } - job.run_job("/comment", "DELETE", json, function(data) + job.run_job("/comment", "DELETE", body, function(data) vim.notify(data.message, vim.log.levels.INFO) if not note_node.is_root then - M.tree:remove_node("-" .. note_id) - M.tree:render() + tree:remove_node("-" .. note_id) -- Note is not a discussion root, safe to remove + tree:render() else - -- We are removing the root node of the discussion, - -- we need to move all the children around, the easiest way - -- to do this is to just re-render the whole tree 🤷 - M.refresh_tree() - note_node:expand() + if unlinked then + M.unlinked_discussions = u.remove_first_value(M.unlinked_discussions) + M.rebuild_unlinked_discussion_tree() + else + M.discussions = u.remove_first_value(M.discussions) + M.rebuild_discussion_tree() + end end + M.switch_can_edit_bufs(true) + M.add_empty_titles({ + { M.linked_section_bufnr, M.discussions, "No Discussions for this MR" }, + { M.unlinked_section_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" } + }) + M.switch_can_edit_bufs(false) end) end end -- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree -M.edit_comment = function() - local current_node = M.tree:get_node() - local note_node = M.get_note_node(current_node) - local root_node = M.get_root_node(current_node) +M.edit_comment = function(tree, unlinked) + local current_node = tree:get_node() + local note_node = M.get_note_node(tree, current_node) + local root_node = M.get_root_node(tree, current_node) edit_popup:mount() 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 = M.tree:get_node(child_id) + local child_node = tree:get_node(child_id) if (not child_node:has_children()) then - local line = M.tree:get_node(child_id).text + local line = 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) - state.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id)) + state.set_popup_keymaps(edit_popup, + M.send_edits(tree, tostring(root_node.id), note_node.root_note_id or note_node.id, unlinked)) end -- This function sends the edited comment to the Go server -M.send_edits = function(discussion_id, note_id) +M.send_edits = function(tree, discussion_id, note_id, unlinked) return function(text) - local json_table = { + local body = { 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) + job.run_job("/comment", "PATCH", body, function(data) vim.notify(data.message, vim.log.levels.INFO) - M.redraw_text(text) + if unlinked then + M.unlinked_discussions = M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) + M.rebuild_unlinked_discussion_tree() + else + M.discussions = M.replace_text(M.discussions, discussion_id, note_id, text) + M.rebuild_discussion_tree() + end end) end end -- This comment (settings.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server -M.toggle_resolved = function() - local note = M.tree:get_node() +M.toggle_resolved = function(tree) + local note = tree:get_node() if not note or not note.resolvable then return end - local json_table = { + local body = { 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) + job.run_job("/comment", "PATCH", body, function(data) vim.notify(data.message, vim.log.levels.INFO) - M.redraw_resolved_status(note, not note.resolved) + M.redraw_resolved_status(tree, note, not note.resolved) end) end -- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer -M.jump_to_reviewer = function() - local file_name, new_line, old_line, error = M.get_note_location() +M.jump_to_reviewer = function(tree) + local file_name, new_line, old_line, error = M.get_note_location(tree) if error ~= nil then vim.notify(error, vim.log.levels.ERROR) return @@ -237,8 +231,8 @@ M.jump_to_reviewer = function() end -- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab -M.jump_to_file = function() - local file_name, new_line, old_line, error = M.get_note_location() +M.jump_to_file = function(tree) + local file_name, new_line, old_line, error = M.get_note_location(tree) if error ~= nil then vim.notify(error, vim.log.levels.ERROR) return @@ -248,24 +242,24 @@ M.jump_to_file = function() end -- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children -M.toggle_node = function() - local node = M.tree:get_node() +M.toggle_node = function(tree) + local node = tree:get_node() if node == nil then return end local children = node:get_child_ids() if node == nil then return end if node:is_expanded() then node:collapse() for _, child in ipairs(children) do - M.tree:get_node(child):collapse() + tree:get_node(child):collapse() end else for _, child in ipairs(children) do - M.tree:get_node(child):expand() + tree:get_node(child):expand() end node:expand() end - M.tree:render() + tree:render() end @@ -273,13 +267,140 @@ end -- 🌲 Helper Functions -- -M.redraw_resolved_status = function(note, mark_resolved) - local current_text = M.tree.nodes.by_id["-" .. note.id].text +M.rebuild_discussion_tree = function() + M.switch_can_edit_bufs(true) + vim.api.nvim_buf_set_lines(M.linked_section_bufnr, 0, -1, false, {}) + local discussion_tree_nodes = M.add_discussions_to_table(M.discussions) + local discussion_tree = NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section_bufnr }) + discussion_tree:render() + M.set_tree_keymaps(discussion_tree, M.linked_section_bufnr, false) + M.discussion_tree = discussion_tree + M.switch_can_edit_bufs(false) +end + +M.rebuild_unlinked_discussion_tree = function() + M.switch_can_edit_bufs(true) + vim.api.nvim_buf_set_lines(M.unlinked_section_bufnr, 0, -1, false, {}) + local unlinked_discussion_tree_nodes = M.add_discussions_to_table(M.unlinked_discussions) + local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, bufnr = M.unlinked_section_bufnr }) + unlinked_discussion_tree:render() + M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section_bufnr, true) + M.unlinked_discussion_tree = unlinked_discussion_tree + M.switch_can_edit_bufs(false) +end + +M.switch_can_edit_bufs = function(bool) + vim.api.nvim_buf_set_option(M.unlinked_section_bufnr, 'modifiable', bool) + vim.api.nvim_buf_set_option(M.unlinked_section_bufnr, "readonly", not bool) + vim.api.nvim_buf_set_option(M.linked_section_bufnr, 'modifiable', bool) + vim.api.nvim_buf_set_option(M.linked_section_bufnr, "readonly", not bool) +end + +M.add_discussion = function(arg) + local discussion = arg.data.discussion + if arg.unlinked then + if type(M.unlinked_discussions) ~= "table" then M.unlinked_discussions = {} end + table.insert(M.unlinked_discussions, 1, discussion) + local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr) + if u.table_size(bufinfo) ~= 0 then + M.rebuild_unlinked_discussion_tree() + end + return + end + if type(M.discussions) ~= "table" then M.discussions = {} end + table.insert(M.discussions, 1, discussion) + local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr) + if u.table_size(bufinfo) ~= 0 then + M.rebuild_discussion_tree() + end +end + +M.create_layout = function() + local linked_section = Split({ enter = true }) + local unlinked_section = Split({}) + + local position = state.settings.discussion_tree.position + local size = state.settings.discussion_tree.size + local relative = state.settings.discussion_tree.relative + + local layout = Layout( + { + position = position, + size = size, + relative = relative, + }, + Layout.Box({ + Layout.Box(linked_section, { size = "50%" }), + Layout.Box(unlinked_section, { size = "50%" }), + }, + { dir = (position == "left" and "col" or "row") } + ) + ) + + return linked_section, unlinked_section, layout +end + +M.add_empty_titles = function(args) + local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") + vim.cmd("highlight default TitleHighlight guifg=#787878") + for _, section in ipairs(args) do + local bufnr, data, title = section[1], section[2], section[3] + if type(data) ~= "table" or #data == 0 then + vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { title }) + local linnr = 1 + vim.api.nvim_buf_set_extmark(bufnr, ns_id, linnr - 1, 0, + { end_row = linnr - 1, end_col = string.len(title), hl_group = 'TitleHighlight' }) + end + end +end + +M.set_tree_keymaps = function(tree, bufnr, unlinked) + vim.keymap.set('n', + state.settings.discussion_tree.edit_comment, + function() M.edit_comment(tree, unlinked) end, + { buffer = bufnr } + ) + vim.keymap.set('n', + state.settings.discussion_tree.delete_comment, + function() M.delete_comment(tree, unlinked) end, + { buffer = bufnr } + ) + vim.keymap.set('n', + state.settings.discussion_tree.toggle_resolved, + function() M.toggle_resolved(tree) end, + { buffer = bufnr } + ) + vim.keymap.set('n', + state.settings.discussion_tree.toggle_node, + function() M.toggle_node(tree, unlinked) end, + { buffer = bufnr } + ) + vim.keymap.set('n', + state.settings.discussion_tree.reply, + function() M.reply(tree) end, + { buffer = bufnr } + ) + + if not unlinked then + vim.keymap.set('n', state.settings.discussion_tree.jump_to_file, function() + M.jump_to_file(tree) + end, { buffer = bufnr } + ) + vim.keymap.set('n', state.settings.discussion_tree.jump_to_reviewer, + function() M.jump_to_reviewer(tree) end, + { buffer = bufnr } + ) + end +end + + +M.redraw_resolved_status = function(tree, note, mark_resolved) + local current_text = 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) - M.tree.nodes.by_id["-" .. note.id][key] = val + tree.nodes.by_id["-" .. note.id][key] = val end local has_symbol = function(s) @@ -298,57 +419,52 @@ M.redraw_resolved_status = function(note, mark_resolved) set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.settings.discussion_tree[target])) end - M.tree:render() + tree:render() end -M.redraw_text = function(text) - local current_node = M.tree:get_node() - local note_node = M.get_note_node(current_node) - - local childrenIds = note_node:get_child_ids() - for _, value in ipairs(childrenIds) do - M.tree:remove_node(value) +M.replace_text = function(data, discussion_id, note_id, text) + for i, discussion in ipairs(data) do + if discussion.id == discussion_id then + for j, note in ipairs(discussion.notes) do + if note.id == note_id then + data[i].notes[j].body = text + return data + end + end + end end - - local newNoteTextNodes = {} - for bodyLine in text:gmatch("[^\n]+") do - table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {})) - end - - M.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id) - M.tree:render() end -M.get_root_node = function(node) +M.get_root_node = function(tree, node) if (not node.is_root) then local parent_id = node:get_parent_id() - return M.get_root_node(M.tree:get_node(parent_id)) + return M.get_root_node(tree, tree:get_node(parent_id)) else return node end end -M.get_note_node = function(node) +M.get_note_node = function(tree, 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(M.tree:get_node(parent_id)) + return M.get_note_node(tree, tree:get_node(parent_id)) else return node end end -local attach_uuid = function(str) +local attach_uuid = function(str) return { text = str, id = u.uuid() } end -M.build_note_body = function(note, resolve_info) +M.build_note_body = function(note, resolve_info) local text_nodes = {} for bodyLine in note.body:gmatch("[^\n]+") do local line = attach_uuid(bodyLine) table.insert(text_nodes, NuiTree.Node({ - new_line = note.position.new_line, - old_line = note.position.old_line, + new_line = (type(note.position) == "table" and note.position.new_line), + old_line = (type(note.position) == "table" and note.position.old_line), text = line.text, id = line.id, is_body = true @@ -366,54 +482,31 @@ M.build_note_body = function(note, resolve_info) return noteHeader, text_nodes end -M.build_note = function(note, resolve_info) +M.build_note = function(note, resolve_info) local text, text_nodes = M.build_note_body(note, resolve_info) local note_node = NuiTree.Node({ text = text, id = note.id, - file_name = note.position.new_path, - new_line = note.position.new_line, - old_line = note.position.old_line, + file_name = (type(note.position) == "table" and note.position.new_path), + new_line = (type(note.position) == "table" and note.position.new_line), + old_line = (type(note.position) == "table" and note.position.old_line), is_note = true, }, text_nodes) return note_node, text, text_nodes end -M.add_note_to_tree = function(note, discussion_id) +M.add_reply_to_tree = function(tree, note, discussion_id) local note_node = M.build_note(note) note_node:expand() - M.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) - M.tree:render() - vim.notify("Sent reply!", vim.log.levels.INFO) + tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) + tree:render() 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 M.split_buf or (vim.fn.bufwinid(M.split_buf) == -1) then return end - - vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', true) - vim.api.nvim_buf_set_option(M.split_buf, 'readonly', false) - vim.api.nvim_buf_set_lines(M.split_buf, 0, -1, false, {}) - vim.api.nvim_buf_set_option(M.split_buf, 'readonly', true) - vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', false) - - local tree_nodes = M.add_discussions_to_table(data.discussions) - M.tree = NuiTree({ nodes = tree_nodes, bufnr = M.split_buf }) - M.set_tree_keymaps() - M.tree:render() - vim.api.nvim_buf_set_option(M.split_buf, 'filetype', 'markdown') - end) -end - -M.add_discussions_to_table = function(discussions) +M.add_discussions_to_table = function(items) local t = {} - for _, discussion in ipairs(discussions) do + for _, discussion in ipairs(items) do local discussion_children = {} -- These properties are filled in by the first note @@ -430,9 +523,10 @@ M.add_discussions_to_table = function(discussions) for j, note in ipairs(discussion.notes) do if j == 1 then __, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable }) - root_file_name = note.position.new_path - root_new_line = note.position.new_line - root_old_line = note.position.old_line + + root_file_name = (type(note.position) == "table" and note.position.new_path) + root_new_line = (type(note.position) == "table" and note.position.new_line) + root_old_line = (type(note.position) == "table" and note.position.old_line) root_id = discussion.id root_note_id = note.id resolvable = note.resolvable @@ -464,10 +558,10 @@ M.add_discussions_to_table = function(discussions) return t end -M.get_note_location = function() - local node = M.tree:get_node() +M.get_note_location = function(tree) + local node = tree:get_node() if node == nil then return nil, nil, nil, "Could not get node" end - local discussion_node = M.get_root_node(node) + local discussion_node = M.get_root_node(tree, node) if discussion_node == nil then return nil, nil, nil, "Could not get discussion node" end return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 4bf5684..8141f09 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -29,9 +29,8 @@ end -- This function will PUT the new description to the Go server M.edit_description = function(text) - local jsonTable = { description = text } - local json = vim.json.encode(jsonTable) - job.run_job("/mr/description", "PUT", json, function(data) + local body = { description = text } + job.run_job("/mr/description", "PUT", body, function(data) vim.notify(data.message, vim.log.levels.INFO) state.INFO.description = data.mr.description end) diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 3a2d3e1..2239cd9 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -21,9 +21,9 @@ function Async:init(cb) self.cb = cb end -function Async:fetch(dependencies, i) +function Async:fetch(dependencies, i, argTable) if i > #dependencies then - self:cb() + self.cb(argTable) return end @@ -31,19 +31,19 @@ function Async:fetch(dependencies, i) -- Do not call endpoint unless refresh is required if state[dependency.state] ~= nil and not dependency.refresh then - self:fetch(dependencies, i + 1) + self:fetch(dependencies, i + 1, argTable) return end job.run_job(dependency.endpoint, "GET", dependency.body, function(data) state[dependency.state] = data[dependency.key] - self:fetch(dependencies, i + 1) + self:fetch(dependencies, i + 1, argTable) end) end -- Will call APIs in sequence and set global state M.sequence = function(dependencies, cb) - return function() + return function(argTable) local handler = Async:new() handler:init(cb) @@ -53,13 +53,13 @@ M.sequence = function(dependencies, cb) end if state.go_server_running then - handler:fetch(dependencies, 1) + handler:fetch(dependencies, 1, argTable) return end server.start(function() state.go_server_running = true - handler:fetch(dependencies, 1) + handler:fetch(dependencies, 1, argTable) end) end end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 11a8017..a024a46 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -29,6 +29,7 @@ return { 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), -- Discussion Tree Actions 🌴 toggle_discussions = async.sequence({ info }, discussions.toggle), diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 27bee94..5d42c5f 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -8,8 +8,9 @@ M.run_job = function(endpoint, method, body, callback) local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint } if body ~= nil then + local encoded_body = vim.json.encode(body) table.insert(args, 1, "-d") - table.insert(args, 2, body) + table.insert(args, 2, encoded_body) end -- This handler will handle all responses from the Go server. Anything with a successful diff --git a/lua/gitlab/reviewer/delta.lua b/lua/gitlab/reviewer/delta.lua index a8a6af1..2295acb 100644 --- a/lua/gitlab/reviewer/delta.lua +++ b/lua/gitlab/reviewer/delta.lua @@ -33,6 +33,7 @@ M.open = function() 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) @@ -42,7 +43,7 @@ M.jump = function(file_name, new_line, old_line) return end - vim.api.nvim_command("wincmd w") + vim.api.nvim_set_current_win(M.winnr) u.jump_to_buffer(M.bufnr, linnr) end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index f1c5498..cbaf89f 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -16,7 +16,7 @@ M.settings = { perform_action = "s", }, discussion_tree = { - toggle = "d", + blacklist = {}, jump_to_file = "o", jump_to_reviewer = "m", edit_comment = "e", @@ -28,7 +28,7 @@ M.settings = { position = "left", size = "20%", resolved = '✓', - unresolved = '' + unresolved = '', }, review_pane = { delta = { diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index b41dcff..37d1e1a 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -105,6 +105,9 @@ end M.merge = function(defaults, overrides) local result = {} + if type(defaults) == "table" and M.table_size(defaults) == 0 and type(overrides) == "table" then + return overrides + end for key, value in pairs(defaults) do if type(value) == "table" then @@ -133,6 +136,15 @@ M.join = function(tbl, separator) return result end +M.remove_first_value = function(tbl) + local sliced_table = {} + for i = 2, #tbl do + table.insert(sliced_table, tbl[i]) + end + + return sliced_table +end + M.read_file = function(file_path) local file = io.open(file_path, "r") if file == nil then @@ -166,6 +178,12 @@ M.join_tables = function(table1, table2) return table1 end +M.table_size = function(t) + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + M.contains = function(array, search_value) for _, value in ipairs(array) do if value == search_value then